Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96be3fdeb3 | ||
|
|
52f1770515 | ||
|
|
89ce7d80dc | ||
|
|
65440d91ee | ||
|
|
1e0bd6f7a2 | ||
|
|
a336ac283d |
No files matched your search
@@ -1612,6 +1612,20 @@ async def _send_message_core(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Phase 1: Bill tokens on stream completion
|
||||||
|
try:
|
||||||
|
_pt = prompt_tokens if "prompt_tokens" in dir() else 0
|
||||||
|
_ct = completion_tokens if "completion_tokens" in dir() else 0
|
||||||
|
_uid = user_id if "user_id" in dir() else "unknown"
|
||||||
|
if _pt or _ct:
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(_bill_tokens(
|
||||||
|
_uid, _pt, _ct,
|
||||||
|
ai_model if "ai_model" in dir() else "unknown",
|
||||||
|
conv_id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
|
|
||||||
@@ -8598,3 +8612,26 @@ def _launch_media_task_bg(ai_msg_id, ai_model_id, prompt, user_id):
|
|||||||
_t = threading.Thread(target=_run, daemon=True)
|
_t = threading.Thread(target=_run, daemon=True)
|
||||||
_t.start()
|
_t.start()
|
||||||
|
|
||||||
|
# Phase 1: Token billing
|
||||||
|
async def _bill_tokens(user_id, prompt_tokens, completion_tokens, model_name, conv_id):
|
||||||
|
try:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from sqlalchemy import text
|
||||||
|
db = SessionLocal()
|
||||||
|
total = prompt_tokens + completion_tokens
|
||||||
|
cost = round(total * 0.002 / 1000, 6)
|
||||||
|
db.execute(text("UPDATE user_balances SET balance = balance - :c, total_used = total_used + :c, updated_at = NOW() WHERE user_id = :u AND balance >= :c"), {"c": cost, "u": user_id})
|
||||||
|
db.execute(text("INSERT INTO token_usage_logs (id, user_id, model_name, conversation_id, prompt_tokens, completion_tokens, total_tokens, cost, created_at) VALUES (gen_random_uuid(), :u, :m, :cv, :pt, :ct, :tt, :c, NOW())"), {"u": user_id, "m": model_name, "cv": conv_id, "pt": prompt_tokens, "ct": completion_tokens, "tt": total, "c": cost})
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user_balance(user_id):
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from sqlalchemy import text
|
||||||
|
db = SessionLocal()
|
||||||
|
r = db.execute(text("SELECT balance, total_used FROM user_balances WHERE user_id = :u"), {"u": user_id}).fetchone()
|
||||||
|
db.close()
|
||||||
|
if r: return {"balance": float(r[0]), "total_used": float(r[1])}
|
||||||
|
return {"balance": 10.0, "total_used": 0}
|
||||||
@@ -1740,3 +1740,112 @@ def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto")
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"
|
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 1: Enhanced SKILL.md parser with 9-block standard
|
||||||
|
SKILLMD_SCHEMA = {
|
||||||
|
"name": "name",
|
||||||
|
"version": "version",
|
||||||
|
"author": "author",
|
||||||
|
"description": "description",
|
||||||
|
"capabilities": "capabilities",
|
||||||
|
"inputs": "inputs",
|
||||||
|
"outputs": "outputs",
|
||||||
|
"permissions": "permissions",
|
||||||
|
"dependencies": "dependencies",
|
||||||
|
"pricing": "pricing",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_skillmd_v2(content: str) -> dict:
|
||||||
|
"""Phase 1: Parse SKILL.md with standardized 9-block schema.
|
||||||
|
|
||||||
|
SKILL.md Standard v2.0 format:
|
||||||
|
---
|
||||||
|
name: Skill Name
|
||||||
|
version: 1.0.0
|
||||||
|
author: Author Name <email>
|
||||||
|
description: |
|
||||||
|
Multi-line description
|
||||||
|
capabilities: [text_generation, code_execution]
|
||||||
|
inputs:
|
||||||
|
- name: input_text
|
||||||
|
type: string
|
||||||
|
description: The input text
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: string
|
||||||
|
description: The processed result
|
||||||
|
permissions: [file_read, network]
|
||||||
|
dependencies: [python>=3.10, requests]
|
||||||
|
pricing:
|
||||||
|
free_tier: 10/day
|
||||||
|
pro_tier: 100/day
|
||||||
|
---
|
||||||
|
Skill instruction content...
|
||||||
|
"""
|
||||||
|
import re, yaml
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"name": None, "version": "1.0.0", "author": None,
|
||||||
|
"description": None, "capabilities": [], "inputs": [],
|
||||||
|
"outputs": [], "permissions": [], "dependencies": [],
|
||||||
|
"pricing": {}, "instructions": "", "valid": False, "errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
result["errors"].append("Empty content")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Extract YAML frontmatter
|
||||||
|
fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)', content, re.DOTALL)
|
||||||
|
|
||||||
|
if fm_match:
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(fm_match.group(1))
|
||||||
|
if isinstance(fm, dict):
|
||||||
|
for key in SKILLMD_SCHEMA:
|
||||||
|
if key in fm:
|
||||||
|
result[key] = fm[key]
|
||||||
|
result["valid"] = True
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
result["errors"].append(f"YAML parse error: {e}")
|
||||||
|
result["instructions"] = fm_match.group(2).strip()
|
||||||
|
else:
|
||||||
|
# No frontmatter: treat entire content as instructions
|
||||||
|
result["instructions"] = content.strip()
|
||||||
|
result["description"] = content[:200].strip()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not result["name"]:
|
||||||
|
result["errors"].append("Missing required field: name")
|
||||||
|
if not result["description"]:
|
||||||
|
result["errors"].append("Missing recommended field: description")
|
||||||
|
|
||||||
|
result["valid"] = len([e for e in result["errors"] if "Missing required" in e]) == 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_skillmd(content: str) -> dict:
|
||||||
|
"""Validate a SKILL.md content against the v2 schema. Returns {valid, errors, warnings}."""
|
||||||
|
parsed = _parse_skillmd_v2(content)
|
||||||
|
errors = list(parsed["errors"])
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if not parsed["name"]:
|
||||||
|
errors.append("REQUIRED: 'name' field is missing")
|
||||||
|
if not parsed["description"]:
|
||||||
|
warnings.append("Recommended: add 'description' field")
|
||||||
|
if not parsed["version"]:
|
||||||
|
warnings.append("Recommended: specify 'version' (e.g. 1.0.0)")
|
||||||
|
if not parsed["capabilities"]:
|
||||||
|
warnings.append("Recommended: list 'capabilities' for marketplace visibility")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
"parsed": parsed
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.api.chat_service import get_user_balance
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc
|
from sqlalchemy import and_, desc, asc
|
||||||
@@ -38,6 +39,12 @@ class UpdateApiKeyRequest(BaseModel):
|
|||||||
status: str # enabled, disabled, revoked
|
status: str # enabled, disabled, revoked
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance")
|
||||||
|
async def get_balance(user=Depends(get_current_user)):
|
||||||
|
balance = await get_user_balance(user["id"])
|
||||||
|
return {"code": 0, "data": balance, "message": "ok"}
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def get_profile(
|
def get_profile(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Phase 1 #1A: Skills API Router
|
||||||
|
POST /api/skills/ppt/generate - Generate PPTX
|
||||||
|
GET /api/skills/ppt/download/{instance_id} - Download PPTX
|
||||||
|
POST /api/skills/seo/analyze - SEO analysis
|
||||||
|
POST /api/skills/research/generate - Investment research
|
||||||
|
GET /api/skills/status/{instance_id} - Check execution status
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.auth import get_current_user, get_current_tenant
|
||||||
|
from app.skill_runtime import PPTSkillRuntime, SEOSkillRuntime, ResearchSkillRuntime
|
||||||
|
from app.database import SessionLocal
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter(tags=["技能执行"])
|
||||||
|
|
||||||
|
class PPTRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
template: str = "business"
|
||||||
|
slides: int = 5
|
||||||
|
|
||||||
|
class SEORequest(BaseModel):
|
||||||
|
url: str = ""
|
||||||
|
keywords: str = ""
|
||||||
|
|
||||||
|
class ResearchRequest(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
|
||||||
|
@router.post("/ppt/generate")
|
||||||
|
async def generate_ppt(req: PPTRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||||
|
"""Generate PPT from text content using AI + python-pptx."""
|
||||||
|
runtime = PPTSkillRuntime(
|
||||||
|
skill_id="ppt-generator",
|
||||||
|
user_id=user["id"],
|
||||||
|
tenant_id=tenant
|
||||||
|
)
|
||||||
|
result = await runtime.execute({"content": req.content, "template": req.template, "slides": req.slides})
|
||||||
|
return {"code": 0, "data": result, "message": "PPT generated" if result["status"] == "ready" else "Failed"}
|
||||||
|
|
||||||
|
@router.get("/ppt/download/{instance_id}")
|
||||||
|
async def download_ppt(instance_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Download generated PPTX file."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
db = SessionLocal()
|
||||||
|
row = db.execute(text(
|
||||||
|
"SELECT result FROM skill_instances WHERE id = :id AND user_id = :uid"
|
||||||
|
), {"id": instance_id, "uid": user["id"]}).fetchone()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Instance not found")
|
||||||
|
# Find workspace path
|
||||||
|
ws = f"/app/workspace/*/{user['id']}/ppt-generator/{instance_id}/output.pptx"
|
||||||
|
import glob
|
||||||
|
matches = glob.glob(ws)
|
||||||
|
if matches:
|
||||||
|
return FileResponse(matches[0], media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
filename="presentation.pptx")
|
||||||
|
raise HTTPException(404, "PPTX file not found")
|
||||||
|
|
||||||
|
@router.post("/seo/analyze")
|
||||||
|
async def analyze_seo(req: SEORequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||||
|
"""Analyze SEO for a URL/keyword."""
|
||||||
|
runtime = SEOSkillRuntime(
|
||||||
|
skill_id="seo-optimizer",
|
||||||
|
user_id=user["id"],
|
||||||
|
tenant_id=tenant
|
||||||
|
)
|
||||||
|
result = await runtime.execute({"url": req.url, "keywords": req.keywords})
|
||||||
|
return {"code": 0, "data": result, "message": "SEO analysis complete"}
|
||||||
|
|
||||||
|
@router.post("/research/generate")
|
||||||
|
async def generate_research(req: ResearchRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||||
|
"""Generate investment research report."""
|
||||||
|
runtime = ResearchSkillRuntime(
|
||||||
|
skill_id="investment-research",
|
||||||
|
user_id=user["id"],
|
||||||
|
tenant_id=tenant
|
||||||
|
)
|
||||||
|
result = await runtime.execute({"symbol": req.symbol})
|
||||||
|
return {"code": 0, "data": result, "message": "Research generated"}
|
||||||
|
|
||||||
|
@router.get("/status/{instance_id}")
|
||||||
|
async def get_skill_status(instance_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Check skill execution status."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
db = SessionLocal()
|
||||||
|
row = db.execute(text(
|
||||||
|
"SELECT status, result, error, created_at FROM skill_instances WHERE id = :id AND user_id = :uid"
|
||||||
|
), {"id": instance_id, "uid": user["id"]}).fetchone()
|
||||||
|
db.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Instance not found")
|
||||||
|
return {"code": 0, "data": {"status": row[0], "result": row[1], "error": row[2],
|
||||||
|
"created_at": str(row[3])}}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
Phase 1 #1A: Skill Runtime Framework v2
|
||||||
|
Real skill execution engine with python-pptx integration.
|
||||||
|
"""
|
||||||
|
import os, json, asyncio, uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRuntime:
|
||||||
|
"""Real skill execution engine.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
authorize() -> prepare_workspace() -> execute() -> store_artifacts() -> record_metrics()
|
||||||
|
|
||||||
|
States: pending -> running -> ready | failed
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, skill_id: str, user_id: str, tenant_id: str = None):
|
||||||
|
self.skill_id = skill_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.tenant_id = tenant_id or "00000000-0000-4000-a000-000000000001"
|
||||||
|
self.instance_id = str(uuid.uuid4())
|
||||||
|
self.workspace = f"/app/workspace/{self.tenant_id}/{self.user_id}/{skill_id}/{self.instance_id}"
|
||||||
|
self.status = "pending"
|
||||||
|
self.result = None
|
||||||
|
self.error = None
|
||||||
|
self.artifacts = []
|
||||||
|
|
||||||
|
async def authorize(self, db) -> bool:
|
||||||
|
"""Check user has permission to execute this skill."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
row = db.execute(text(
|
||||||
|
"SELECT 1 FROM app_installations WHERE user_id = :uid AND app_id = :sid AND status = 'active'"
|
||||||
|
), {"uid": self.user_id, "sid": self.skill_id}).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
async def prepare_workspace(self):
|
||||||
|
"""Create isolated workspace directory."""
|
||||||
|
os.makedirs(self.workspace, exist_ok=True)
|
||||||
|
return self.workspace
|
||||||
|
|
||||||
|
async def execute(self, inputs: dict) -> dict:
|
||||||
|
"""Execute the skill with provided inputs."""
|
||||||
|
self.status = "running"
|
||||||
|
try:
|
||||||
|
await self.prepare_workspace()
|
||||||
|
self.result = await self._run(inputs)
|
||||||
|
self.status = "ready"
|
||||||
|
await self.store_artifacts()
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "failed"
|
||||||
|
self.error = str(e)
|
||||||
|
await self.record_metrics()
|
||||||
|
return {"status": self.status, "result": self.result, "error": self.error,
|
||||||
|
"instance_id": self.instance_id}
|
||||||
|
|
||||||
|
async def _run(self, inputs: dict) -> dict:
|
||||||
|
"""Override in skill-specific subclasses."""
|
||||||
|
raise NotImplementedError("Each skill must implement _run()")
|
||||||
|
|
||||||
|
async def store_artifacts(self):
|
||||||
|
"""Save result metadata to workspace."""
|
||||||
|
artifact_path = os.path.join(self.workspace, "result.json")
|
||||||
|
with open(artifact_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"status": self.status, "result": self.result,
|
||||||
|
"error": self.error, "instance_id": self.instance_id,
|
||||||
|
"artifacts": self.artifacts}, f, ensure_ascii=False, default=str)
|
||||||
|
return artifact_path
|
||||||
|
|
||||||
|
async def record_metrics(self):
|
||||||
|
"""Log execution to DB."""
|
||||||
|
try:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from sqlalchemy import text
|
||||||
|
db = SessionLocal()
|
||||||
|
db.execute(text(
|
||||||
|
"INSERT INTO skill_instances (id, skill_id, user_id, tenant_id, status, result, error, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :sid, :uid, :tid, :st, :res, :err, NOW(), NOW())"
|
||||||
|
"ON CONFLICT (id) DO UPDATE SET status=:st2, result=:res2, error=:err2, updated_at=NOW()"
|
||||||
|
), {"id": self.instance_id, "sid": self.skill_id, "uid": self.user_id,
|
||||||
|
"tid": self.tenant_id, "st": self.status, "res": str(self.result)[:2000] if self.result else None,
|
||||||
|
"err": self.error, "st2": self.status, "res2": str(self.result)[:2000] if self.result else None,
|
||||||
|
"err2": self.error})
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PPTSkillRuntime(SkillRuntime):
|
||||||
|
"""Real AI PPT Generator using python-pptx."""
|
||||||
|
|
||||||
|
TEMPLATES = {
|
||||||
|
"business": {"bg": "FFFFFF", "title_color": "1A3C5E", "accent": "2E86C1"},
|
||||||
|
"creative": {"bg": "FFF8E1", "title_color": "E65100", "accent": "FF6F00"},
|
||||||
|
"minimal": {"bg": "FFFFFF", "title_color": "212121", "accent": "616161"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _run(self, inputs: dict) -> dict:
|
||||||
|
content = inputs.get("content", "")
|
||||||
|
template = inputs.get("template", "business")
|
||||||
|
slides_count = int(inputs.get("slides", 5))
|
||||||
|
|
||||||
|
if not content.strip():
|
||||||
|
return {"error": "No content provided", "status": "failed"}
|
||||||
|
|
||||||
|
# Step 1: Generate slide outline via LLM
|
||||||
|
outline = await self._generate_outline(content, slides_count)
|
||||||
|
if not outline:
|
||||||
|
return {"error": "Failed to generate outline", "status": "failed"}
|
||||||
|
|
||||||
|
# Step 2: Generate PPTX file
|
||||||
|
pptx_path = os.path.join(self.workspace, "output.pptx")
|
||||||
|
self._create_pptx(outline, pptx_path, template)
|
||||||
|
self.artifacts.append({"name": "output.pptx", "path": pptx_path,
|
||||||
|
"type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"slides": len(outline),
|
||||||
|
"title": outline[0]["title"] if outline else content[:50],
|
||||||
|
"template": template,
|
||||||
|
"status": "ready",
|
||||||
|
"pptx_path": pptx_path,
|
||||||
|
"outline": outline[:3],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _generate_outline(self, content: str, slide_count: int) -> list:
|
||||||
|
"""Call LLM via LiteLLM gateway to generate slide outline."""
|
||||||
|
try:
|
||||||
|
prompt = (
|
||||||
|
f"You are a professional PPT designer. Create a {slide_count}-slide presentation "
|
||||||
|
f"outline based on this content:\n\n{content[:3000]}\n\n"
|
||||||
|
f"Return ONLY valid JSON array format:\n"
|
||||||
|
f'[{{"title":"Slide Title","bullets":["point1","point2","point3"]}}]\n'
|
||||||
|
f"Do NOT include any other text, markdown, or explanation."
|
||||||
|
)
|
||||||
|
# Use OpenAI compatible API through LiteLLM
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 2000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return self._fallback_outline(content, slide_count)
|
||||||
|
|
||||||
|
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
# Clean markdown code fences
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[1]
|
||||||
|
if text.endswith("```"):
|
||||||
|
text = text[:-3]
|
||||||
|
outline = json.loads(text)
|
||||||
|
if isinstance(outline, list) and len(outline) > 0:
|
||||||
|
return outline[:slide_count]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._fallback_outline(content, slide_count)
|
||||||
|
|
||||||
|
def _fallback_outline(self, content: str, count: int) -> list:
|
||||||
|
"""Generate basic outline without LLM (offline fallback)."""
|
||||||
|
sentences = [s.strip() for s in content.replace("\n", " ").split("。") if s.strip()]
|
||||||
|
if len(sentences) < 3:
|
||||||
|
sentences = content.split("\n")
|
||||||
|
slides = []
|
||||||
|
for i in range(min(count, max(len(sentences), 3))):
|
||||||
|
slides.append({
|
||||||
|
"title": f"第{i+1}部分",
|
||||||
|
"bullets": [sentences[i % len(sentences)][:100] if sentences else f"要点{i+1}"]
|
||||||
|
})
|
||||||
|
return slides
|
||||||
|
|
||||||
|
def _create_pptx(self, outline: list, output_path: str, template: str):
|
||||||
|
"""Create actual PPTX file using python-pptx."""
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches, Pt
|
||||||
|
from pptx.dml.color import RGBColor
|
||||||
|
from pptx.enum.text import PP_ALIGN
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
prs.slide_width = Inches(13.333)
|
||||||
|
prs.slide_height = Inches(7.5)
|
||||||
|
style = self.TEMPLATES.get(template, self.TEMPLATES["business"])
|
||||||
|
|
||||||
|
for slide_data in outline:
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||||
|
bg = slide.background
|
||||||
|
fill = bg.fill
|
||||||
|
fill.solid()
|
||||||
|
fill.fore_color.rgb = RGBColor.from_string(style["bg"])
|
||||||
|
|
||||||
|
# Title
|
||||||
|
left, top, width, height = Inches(1), Inches(0.5), Inches(11), Inches(1.2)
|
||||||
|
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||||
|
tf = txBox.text_frame
|
||||||
|
tf.word_wrap = True
|
||||||
|
p = tf.paragraphs[0]
|
||||||
|
p.text = slide_data.get("title", "Untitled")
|
||||||
|
p.font.size = Pt(36)
|
||||||
|
p.font.bold = True
|
||||||
|
p.font.color.rgb = RGBColor.from_string(style["title_color"])
|
||||||
|
p.alignment = PP_ALIGN.LEFT
|
||||||
|
|
||||||
|
# Accent line
|
||||||
|
line = slide.shapes.add_shape(
|
||||||
|
1, # MSO_SHAPE.RECTANGLE
|
||||||
|
Inches(1), Inches(1.75), Inches(3), Inches(0.06)
|
||||||
|
)
|
||||||
|
line.fill.solid()
|
||||||
|
line.fill.fore_color.rgb = RGBColor.from_string(style["accent"])
|
||||||
|
line.line.fill.background()
|
||||||
|
|
||||||
|
# Bullets
|
||||||
|
top = Inches(2.2)
|
||||||
|
txBox2 = slide.shapes.add_textbox(Inches(1), top, Inches(11), Inches(4.5))
|
||||||
|
tf2 = txBox2.text_frame
|
||||||
|
tf2.word_wrap = True
|
||||||
|
bullets = slide_data.get("bullets", [])
|
||||||
|
for j, bullet in enumerate(bullets):
|
||||||
|
if j == 0:
|
||||||
|
p2 = tf2.paragraphs[0]
|
||||||
|
else:
|
||||||
|
p2 = tf2.add_paragraph()
|
||||||
|
p2.text = f" {bullet}"
|
||||||
|
p2.font.size = Pt(20)
|
||||||
|
p2.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
||||||
|
p2.space_after = Pt(12)
|
||||||
|
p2.level = 0
|
||||||
|
|
||||||
|
prs.save(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
class SEOSkillRuntime(SkillRuntime):
|
||||||
|
"""Real SEO Optimizer skill."""
|
||||||
|
|
||||||
|
async def _run(self, inputs: dict) -> dict:
|
||||||
|
url = inputs.get("url", "")
|
||||||
|
keywords = inputs.get("keywords", "")
|
||||||
|
|
||||||
|
if not url and not keywords:
|
||||||
|
return {"error": "URL or keywords required", "status": "failed"}
|
||||||
|
|
||||||
|
# Generate SEO analysis via LLM
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
prompt = (
|
||||||
|
f"Analyze SEO for: URL={url}, Keywords={keywords}. "
|
||||||
|
f"Return JSON: {{\"score\":<0-100>,\"title_suggestions\":[str],"
|
||||||
|
f"\"meta_description\":str,\"h1_suggestions\":[str],\"keyword_density\":<percent>,"
|
||||||
|
f"\"improvements\":[str]}}. Only JSON, no markdown."
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||||
|
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 1000, "temperature": 0.5}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
|
||||||
|
result = json.loads(text)
|
||||||
|
else:
|
||||||
|
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
|
||||||
|
except Exception:
|
||||||
|
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
|
||||||
|
|
||||||
|
# Save report
|
||||||
|
report_path = os.path.join(self.workspace, "seo_report.json")
|
||||||
|
with open(report_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"url": url, "keywords": keywords, "analysis": result,
|
||||||
|
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
|
||||||
|
self.artifacts.append({"name": "seo_report.json", "path": report_path, "type": "application/json"})
|
||||||
|
|
||||||
|
return {"score": result.get("score", 0), "improvements": result.get("improvements", []),
|
||||||
|
"status": "ready"}
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchSkillRuntime(SkillRuntime):
|
||||||
|
"""Real AI Investment Research skill."""
|
||||||
|
|
||||||
|
async def _run(self, inputs: dict) -> dict:
|
||||||
|
symbol = inputs.get("symbol", "").upper()
|
||||||
|
|
||||||
|
if not symbol:
|
||||||
|
return {"error": "Stock symbol required", "status": "failed"}
|
||||||
|
|
||||||
|
# Generate research via LLM
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
prompt = (
|
||||||
|
f"Generate investment research report for stock {symbol}. "
|
||||||
|
f"Return JSON: {{\"company_name\":str,\"sector\":str,\"market_cap\":str,"
|
||||||
|
f"\"revenue_growth\":str,\"pe_ratio\":<float>,\"analyst_target\":<float>,"
|
||||||
|
f"\"strengths\":[str],\"risks\":[str],\"recommendation\":\"buy|hold|sell\","
|
||||||
|
f"\"summary\":str}}. Only JSON, no markdown."
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||||
|
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 1500, "temperature": 0.3}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
|
||||||
|
result = json.loads(text)
|
||||||
|
else:
|
||||||
|
result = {"company_name": symbol, "recommendation": "hold", "summary": "Insufficient data"}
|
||||||
|
except Exception:
|
||||||
|
result = {"company_name": symbol, "recommendation": "hold", "summary": "Analysis unavailable"}
|
||||||
|
|
||||||
|
# Save report
|
||||||
|
report_path = os.path.join(self.workspace, "research_report.json")
|
||||||
|
with open(report_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"symbol": symbol, "report": result,
|
||||||
|
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
|
||||||
|
self.artifacts.append({"name": "research_report.json", "path": report_path, "type": "application/json"})
|
||||||
|
|
||||||
|
return {"company": result.get("company_name", symbol),
|
||||||
|
"recommendation": result.get("recommendation", "hold"),
|
||||||
|
"summary": result.get("summary", "")[:200],
|
||||||
|
"status": "ready"}
|
||||||
@@ -39,7 +39,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution, skills_api
|
||||||
from app.casdoor_auth import router as casdoor_router
|
from app.casdoor_auth import router as casdoor_router
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
||||||
from app.api.im_bot_user import router as im_bot_user_router
|
from app.api.im_bot_user import router as im_bot_user_router
|
||||||
@@ -243,6 +243,7 @@ app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中
|
|||||||
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
||||||
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
||||||
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
||||||
|
app.include_router(skills_api.router, prefix="/app/api/skills", tags=["技能执行"])
|
||||||
|
|
||||||
# 后台 API
|
# 后台 API
|
||||||
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
|
|
||||||
model_list:
|
model_list:
|
||||||
- model_name: deepseek-v4-flash
|
- model_name: deepseek-v4-flash
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: deepseek-ai/DeepSeek-V4-Flash
|
model: deepseek-ai/DeepSeek-V4-Flash
|
||||||
api_base: https://api.siliconflow.cn/v1
|
api_base: https://api.siliconflow.cn/v1
|
||||||
api_key: os.environ/SILICONFLOW_API_KEY
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 1000
|
rpm: 1000
|
||||||
tpm: 200000
|
tpm: 200000
|
||||||
- model_name: agnes-2.0-flash
|
- model_name: agnes-2.0-flash
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/agnes-2.0-flash
|
model: openai/agnes-2.0-flash
|
||||||
api_base: https://ai.qihuapi.com/v1
|
api_base: https://ai.qihuapi.com/v1
|
||||||
api_key: os.environ/AGNES_API_KEY
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 500
|
rpm: 500
|
||||||
- model_name: mimo-v2.5
|
- model_name: mimo-v2.5
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/mimo-v2.5
|
model: openai/mimo-v2.5
|
||||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||||
api_key: os.environ/MIMO_API_KEY
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 500
|
rpm: 500
|
||||||
- model_name: qwen3-vl-32b
|
- model_name: qwen3-vl-32b
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/Qwen3-VL-32B-Instruct
|
model: openai/Qwen3-VL-32B-Instruct
|
||||||
api_base: https://api.siliconflow.cn/v1
|
api_base: https://api.siliconflow.cn/v1
|
||||||
api_key: os.environ/SILICONFLOW_API_KEY
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 300
|
rpm: 300
|
||||||
|
|
||||||
litellm_settings:
|
litellm_settings:
|
||||||
@@ -31,7 +30,7 @@ litellm_settings:
|
|||||||
request_timeout: 120
|
request_timeout: 120
|
||||||
|
|
||||||
general_settings:
|
general_settings:
|
||||||
master_key: os.environ/LITELLM_MASTER_KEY
|
master_key: sk-litellm-master-key-aiflow-2026
|
||||||
|
|
||||||
router_settings:
|
router_settings:
|
||||||
routing_strategy: "usage-based-routing-v2"
|
routing_strategy: "usage-based-routing-v2"
|
||||||
|
|||||||
+11
-7
@@ -48,7 +48,7 @@ services:
|
|||||||
- DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash
|
- DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash
|
||||||
- HTTP_PROXY=http://172.18.0.1:7890
|
- HTTP_PROXY=http://172.18.0.1:7890
|
||||||
- HTTPS_PROXY=http://172.18.0.1:7890
|
- HTTPS_PROXY=http://172.18.0.1:7890
|
||||||
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
|
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.2,qihu-aiflow-litellm,qihu-aiflow-meilisearch,qihu-aiflow-pg18,qihu-aiflow-redis,qihu-aiflow-casdoor18,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -84,21 +84,25 @@ services:
|
|||||||
litellm:
|
litellm:
|
||||||
image: ghcr.io/berriai/litellm:main-stable
|
image: ghcr.io/berriai/litellm:main-stable
|
||||||
container_name: qihu-aiflow-litellm
|
container_name: qihu-aiflow-litellm
|
||||||
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
ports:
|
ports:
|
||||||
- "14000:4000"
|
- "14000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/litellm_config.yaml:/app/config.yaml
|
- ./config/litellm_config.yaml:/app/config.yaml
|
||||||
environment:
|
environment:
|
||||||
|
- HTTP_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
|
||||||
|
- HTTPS_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
|
||||||
|
- NO_PROXY=localhost,127.0.0.1,qihu-aiflow-pg18
|
||||||
- LITELLM_MASTER_KEY=sk-litellm-master-key-aiflow-2026
|
- LITELLM_MASTER_KEY=sk-litellm-master-key-aiflow-2026
|
||||||
- SILICONFLOW_API_KEY=os.environ/SILICONFLOW_API_KEY
|
- SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
- AGNES_API_KEY=os.environ/AGNES_API_KEY
|
- AGNES_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
- MIMO_API_KEY=os.environ/MIMO_API_KEY
|
- LITELLM_CONFIG=/app/config.yaml
|
||||||
- DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/aiflow
|
- MIMO_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/litellm
|
||||||
|
- LITELLM_MODE=PRODUCTION
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- aiflow-network
|
- aiflow-network
|
||||||
|
|
||||||
# Phase 0C: Meilisearch - Full-text search engine
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.11
|
image: getmeili/meilisearch:v1.11
|
||||||
container_name: qihu-aiflow-meilisearch
|
container_name: qihu-aiflow-meilisearch
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const iconPaths: Record<string, JSX.Element> = {
|
|||||||
|
|
||||||
const FrontLayout: React.FC = () => {
|
const FrontLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { balance } = useBalance();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
@@ -97,6 +98,12 @@ const FrontLayout: React.FC = () => {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
|
{!collapsed && balance !== null && (
|
||||||
|
<div className="nav-item balance-item">
|
||||||
|
<span className="nav-icon">$</span>
|
||||||
|
<span className="nav-label">余额: $ {balance.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
|
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
|
||||||
<span className="nav-item-icon">
|
<span className="nav-item-icon">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
|||||||
@@ -484,6 +484,44 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
|
|
||||||
|
{/* SKILL.md Structured Info */}
|
||||||
|
{app.skillInfo && (
|
||||||
|
<div className="modal-skill-params">
|
||||||
|
<h4>技能参数</h4>
|
||||||
|
{app.skillInfo.inputs && app.skillInfo.inputs.length > 0 && (
|
||||||
|
<div className="skill-param-group">
|
||||||
|
<span className="skill-param-label">输入:</span>
|
||||||
|
{app.skillInfo.inputs.map((inp, i) => (
|
||||||
|
<span key={i} className="skill-param-tag" title={inp.description}>{inp.name} ({inp.type})</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{app.skillInfo.outputs && app.skillInfo.outputs.length > 0 && (
|
||||||
|
<div className="skill-param-group">
|
||||||
|
<span className="skill-param-label">输出:</span>
|
||||||
|
{app.skillInfo.outputs.map((out, i) => (
|
||||||
|
<span key={i} className="skill-param-tag" title={out.description}>{out.name} ({out.type})</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{app.skillInfo.pricing && (
|
||||||
|
<div className="skill-param-group">
|
||||||
|
<span className="skill-param-label">定价:</span>
|
||||||
|
<span className="skill-param-tag">{app.skillInfo.pricing.free_tier || '免费使用'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{app.skillInfo.dependencies && app.skillInfo.dependencies.length > 0 && (
|
||||||
|
<div className="skill-param-group">
|
||||||
|
<span className="skill-param-label">依赖:</span>
|
||||||
|
{app.skillInfo.dependencies.map((d, i) => (
|
||||||
|
<span key={i} className="skill-param-tag">{d}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{reviews.length > 0 && (
|
{reviews.length > 0 && (
|
||||||
<div className="modal-reviews">
|
<div className="modal-reviews">
|
||||||
<h4>用户评价 ({reviews.length})</h4>
|
<h4>用户评价 ({reviews.length})</h4>
|
||||||
|
|||||||
Reference in new issue
Block a user