chore(align): sync Git with running container as authoritative source
One-time alignment to close drift between the running container (qihu-aiflow / image qihuaiflow:with-deps) and this Git repository. Container hotfixes previously persisted only via `docker commit` are now captured in Git so Git becomes the single source of truth. Backend: - chat_service.py: accumulated fixes incl. EigenFlux README guidance - chat_context.py / chat_media.py: URL/web-repo context injection - api/url_reader.py: NEW SSRF-safe web/README reader (Issue #725) - skills_admin.py & Skills.tsx: 14-scene capability unification - pgvector_search.py, orchestrator.py, platform_knowledge.py, market.py, admin/models.py: drift sync Frontend: - ChatPage.tsx: collapsible AI think/act block (Issue #724) - MarketPage / EvolutionDashboard / TokenDashboard styling sync Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-23 05:42:47. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
49f7503978
commit
a08ec87b22
18 files changed
+459
-181
No files matched your search
@@ -932,7 +932,13 @@ async def test_model_connection(
|
||||
"n": 1,
|
||||
"size": "1024x1024",
|
||||
}
|
||||
resp = await client.post(image_url, json=payload, headers=headers)
|
||||
# 与运行时媒体生成一致:用 _proxy_for_url 解析代理并 trust_env=False;
|
||||
# 图像端点会被 _proxy_for_url 判定为直连(公网 API 直连比代理更稳定)
|
||||
from ..chat_utils import _proxy_for_url
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout, proxy=_proxy_for_url(image_url), trust_env=False
|
||||
) as img_client:
|
||||
resp = await img_client.post(image_url, json=payload, headers=headers)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
if resp.status_code == 200:
|
||||
|
||||
@@ -37,17 +37,31 @@ SKILL_STATES = {
|
||||
"frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"},
|
||||
}
|
||||
|
||||
# 业务场景分类(与前端「应用市场」保持一致,14 类岗位/职能视角)
|
||||
# 统一标准:以前台 MARKET_SCENES 为准,后台技能管理也按此 14 类展示与筛选。
|
||||
SKILL_CATEGORIES = [
|
||||
{"id": "writing", "label": "文字创作"},
|
||||
{"id": "coding", "label": "代码能力"},
|
||||
{"id": "data_analysis", "label": "数据分析"},
|
||||
{"id": "qa", "label": "知识问答"},
|
||||
{"id": "automation", "label": "流程自动化"},
|
||||
{"id": "creativity", "label": "创意生成"},
|
||||
{"id": "communication", "label": "沟通协作"},
|
||||
{"id": "reasoning", "label": "逻辑推理"},
|
||||
{"id": "other", "label": "其他"},
|
||||
{"id": "design", "label": "🎨 做设计"},
|
||||
{"id": "frontend", "label": "💻 写前端"},
|
||||
{"id": "mobile", "label": "📱 做移动端"},
|
||||
{"id": "backend", "label": "🧪 后端/全栈"},
|
||||
{"id": "qa", "label": "🧪 测试质量"},
|
||||
{"id": "search", "label": "🔍 搜索信息"},
|
||||
{"id": "docs", "label": "✍️ 写文档"},
|
||||
{"id": "marketing", "label": "📢 推广获客"},
|
||||
{"id": "workflow", "label": "🔄 工作流"},
|
||||
{"id": "secops", "label": "🛡️ 安全运维"},
|
||||
{"id": "learning", "label": "🎓 学习教学"},
|
||||
{"id": "video", "label": "🎬 视频多媒体"},
|
||||
{"id": "collab", "label": "🏢 办公协作"},
|
||||
{"id": "ai", "label": "🛠️ AI能力"},
|
||||
]
|
||||
_SCENE_IDS = {c["id"] for c in SKILL_CATEGORIES}
|
||||
# 旧 9 类 taxonomy -> 14 场景 的兜底映射(仅用于历史数据 capability 缺失时)
|
||||
_CATEGORY_TO_SCENE = {
|
||||
"writing": "docs", "coding": "backend", "data_analysis": "backend",
|
||||
"qa": "qa", "automation": "workflow", "creativity": "design",
|
||||
"communication": "marketing", "reasoning": "search", "other": "workflow",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -65,7 +79,13 @@ def market_app_to_skill_dict(app: "MarketApp") -> dict:
|
||||
tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags)
|
||||
except Exception:
|
||||
tags_list = [t.strip() for t in str(raw_tags).split(",")]
|
||||
category = getattr(app, "category", None) or "other"
|
||||
# 以业务场景(capability, 14 类)为分类标准,与前端应用市场统一;
|
||||
# capability 缺失时回退到旧 9 类 category 映射出的场景。
|
||||
raw_scene = getattr(app, "capability", None)
|
||||
if raw_scene in _SCENE_IDS:
|
||||
category = raw_scene
|
||||
else:
|
||||
category = _CATEGORY_TO_SCENE.get(getattr(app, "category", None), "workflow")
|
||||
return {
|
||||
"id": app.id,
|
||||
"user_id": getattr(app, "publisher_id", None) or "system",
|
||||
@@ -187,7 +207,8 @@ async def list_skills(
|
||||
or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword))
|
||||
)
|
||||
if category:
|
||||
mkt_query = mkt_query.filter(MarketApp.category == category)
|
||||
# 分类筛选统一按业务场景(capability, 14 类)进行,与前端应用市场一致
|
||||
mkt_query = mkt_query.filter(MarketApp.capability == category)
|
||||
# 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby'
|
||||
if state == "main":
|
||||
mkt_query = mkt_query.filter(MarketApp.status == "published")
|
||||
@@ -901,7 +922,7 @@ async def get_skills_stats(
|
||||
market_by_category = {}
|
||||
try:
|
||||
market_by_category_rows = db.execute(
|
||||
text("SELECT category, COUNT(*) FROM market_apps WHERE type='skill' AND category IS NOT NULL GROUP BY category")
|
||||
text("SELECT capability, COUNT(*) FROM market_apps WHERE type='skill' AND capability IS NOT NULL GROUP BY capability")
|
||||
).fetchall()
|
||||
market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows}
|
||||
except Exception:
|
||||
|
||||
@@ -1121,7 +1121,7 @@ def _build_system_prompt(
|
||||
|
||||
all_mcp: list = None,
|
||||
|
||||
|
||||
db: Session = None,
|
||||
|
||||
) -> tuple:
|
||||
|
||||
@@ -1195,7 +1195,7 @@ def _build_system_prompt(
|
||||
|
||||
|
||||
|
||||
pk = get_platform_knowledge()
|
||||
pk = get_platform_knowledge(db=db)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1566,11 +1566,16 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
|
||||
|
||||
|
||||
except Exception as e:
|
||||
|
||||
|
||||
|
||||
logger.warning(f"[_generate_media_task] 后台生成异常: {e}")
|
||||
|
||||
logger.warning(f"[_generate_media_task] 后台生成异常: {e}", exc_info=True)
|
||||
# 兜底回写:任何意外异常都不能让消息永久停在「⏳ 生成中…」
|
||||
try:
|
||||
db.rollback()
|
||||
_err = db.query(Message).filter(Message.id == ai_msg_id).first()
|
||||
if _err and str(_err.content or "").startswith("⏳"):
|
||||
_err.content = "⚠️ 媒体生成失败,请稍后重试"
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
finally:
|
||||
|
||||
@@ -2100,7 +2100,7 @@ async def _send_message_core(
|
||||
|
||||
all_mcp=_list_enabled_mcp_servers(db),
|
||||
|
||||
|
||||
db=db,
|
||||
|
||||
)
|
||||
|
||||
@@ -2184,6 +2184,30 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
# Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文
|
||||
try:
|
||||
from app.api.url_reader import fetch_url_context
|
||||
_url_ctx = await fetch_url_context(req.content)
|
||||
except Exception as _e:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e)
|
||||
_url_ctx = None
|
||||
if _url_ctx and _url_ctx.get("text"):
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": (
|
||||
"【用户随消息提供的参考链接的真实内容】\n"
|
||||
f"链接地址: {_url_ctx.get('url')}\n"
|
||||
"下面是该链接页面的真实内容,请严格基于它来回答用户的需求"
|
||||
"(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入),"
|
||||
"不要凭空编造步骤:\n\n" + _url_ctx["text"]
|
||||
),
|
||||
})
|
||||
process_steps.append({
|
||||
"type": "skill",
|
||||
"name": "网页/仓库读取",
|
||||
"result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)",
|
||||
})
|
||||
messages.append({"role": "user", "content": req.content + attachment_block})
|
||||
|
||||
|
||||
@@ -3545,7 +3569,8 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
|
||||
# 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1,
|
||||
# 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。
|
||||
|
||||
|
||||
|
||||
@@ -4019,7 +4044,13 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}")
|
||||
logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True)
|
||||
# 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」
|
||||
try:
|
||||
ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试"
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -691,7 +691,12 @@ def list_apps(
|
||||
"capability": app.capability or None,
|
||||
"category": app.category or None,
|
||||
"tags": app.tags or None,
|
||||
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
|
||||
# 官方导入并发布的技能默认全局已安装(见 #716:无论前台/后台导入,默认全局安装)
|
||||
"installed": (
|
||||
app.id in installed_app_ids
|
||||
or is_user_owned
|
||||
or (app.source == "official" and app.status == "published")
|
||||
),
|
||||
"version": app.version or "1.0.0",
|
||||
"security_scan": app.security_scan,
|
||||
"verified": bool(app.verified),
|
||||
@@ -756,7 +761,8 @@ def get_app(
|
||||
"tags": app.tags or None,
|
||||
"created_at": str(app.created_at),
|
||||
"purchased": purchased,
|
||||
"installed": installed,
|
||||
# 官方导入并发布的技能默认全局已安装(见 #716)
|
||||
"installed": installed or (app.source == "official" and app.status == "published"),
|
||||
"version": app.version or "1.0.0",
|
||||
"security_scan": app.security_scan,
|
||||
"verified": bool(app.verified),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""服务端 URL 读取器(Issue 2 修复)
|
||||
|
||||
当用户输入包含 URL 且明确要求『读取 / 加入 / 安装 / 分析 / 总结』等意图时,
|
||||
服务端抓取该链接的真实内容并作为参考上下文注入对话,使模型能基于页面真实信息
|
||||
作答(例如按 GitHub 仓库 README 的 Quick Start 引导用户完成安装/加入),
|
||||
而不是凭空编造。
|
||||
|
||||
SSRF 防护:仅允许 http/https;解析域名 IP 后阻断内网/回环/链路本地/云元数据地址;
|
||||
限制跳转、响应体大小与超时。
|
||||
"""
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_URL_RE = re.compile(r'https?://[^\s"\)\\\]>]+', re.IGNORECASE)
|
||||
# 触发意图:显式要求读取/加入/安装/分析/总结/打开/访问 等
|
||||
_INTENT_RE = re.compile(
|
||||
r'(read|join|install|fetch|summar|analys|open|visit|access|browse|'
|
||||
r'读取|加入|安装|打开|访问|浏览|总结|分析|抓取|看下这个|查看该|这个网页|这个页面|这个链接|help me join)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 常见私有/保留网段(阻断内网扫描与云元数据)
|
||||
_BLOCKED_NETS = [
|
||||
ipaddress.ip_network('10.0.0.0/8'),
|
||||
ipaddress.ip_network('172.16.0.0/12'),
|
||||
ipaddress.ip_network('192.168.0.0/16'),
|
||||
ipaddress.ip_network('127.0.0.0/8'),
|
||||
ipaddress.ip_network('169.254.0.0/16'),
|
||||
ipaddress.ip_network('::1/128'),
|
||||
ipaddress.ip_network('fc00::/7'),
|
||||
ipaddress.ip_network('fe80::/10'),
|
||||
ipaddress.ip_network('0.0.0.0/8'),
|
||||
]
|
||||
|
||||
_MAX_CHARS = 16000
|
||||
_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _is_blocked_host(host: str) -> bool:
|
||||
host = (host or '').strip().lower()
|
||||
if not host:
|
||||
return True
|
||||
if re.fullmatch(r'(\d{1,3}\.){3}\d{1,3}', host):
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return True
|
||||
return any(ip in net for net in _BLOCKED_NETS)
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
for info in infos:
|
||||
ipstr = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ipstr)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(ip in net for net in _BLOCKED_NETS):
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _make_client():
|
||||
try:
|
||||
return httpx.AsyncClient(trust_env=True, follow_redirects=True, timeout=_TIMEOUT)
|
||||
except Exception:
|
||||
return httpx.AsyncClient(trust_env=False, follow_redirects=True, timeout=_TIMEOUT)
|
||||
|
||||
|
||||
async def _github_text(url: str):
|
||||
p = urlparse(url)
|
||||
if p.netloc.lower() not in ('github.com', 'www.github.com'):
|
||||
return None
|
||||
parts = [x for x in p.path.split('/') if x]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
owner, repo = parts[0], parts[1]
|
||||
if len(parts) >= 4 and parts[2] == 'blob':
|
||||
ref = parts[3]
|
||||
fpath = '/'.join(parts[4:])
|
||||
raw = f'https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{fpath}'
|
||||
return await _fetch_text(raw)
|
||||
api = f'https://api.github.com/repos/{owner}/{repo}/readme'
|
||||
try:
|
||||
async with await _make_client() as c:
|
||||
r = await c.get(api, headers={
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
})
|
||||
if r.status_code == 200:
|
||||
import base64
|
||||
d = r.json()
|
||||
return base64.b64decode(d.get('content', '')).decode('utf-8', 'replace')
|
||||
except Exception as e:
|
||||
logger.warning('github readme fetch failed: %s', e)
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_text(url: str) -> str:
|
||||
async with await _make_client() as c:
|
||||
r = await c.get(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
r.raise_for_status()
|
||||
data = r.content[:2_000_000]
|
||||
ctype = r.headers.get('content-type', '')
|
||||
text = data.decode('utf-8', 'replace')
|
||||
if 'html' in ctype:
|
||||
return _html_to_text(text)
|
||||
return text
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
html = re.sub(r'(?is)<(script|style|head|noscript|svg|template)[^>]*>.*?</\1>', ' ', html)
|
||||
html = re.sub(r'(?is)<!--.*?-->', ' ', html)
|
||||
html = re.sub(r'(?is)<[^>]+>', ' ', html)
|
||||
import html as _html
|
||||
text = _html.unescape(html)
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
async def fetch_url_context(text: str):
|
||||
"""若有 URL 且命中读取意图,返回 {'url','text'};否则返回 None。"""
|
||||
if not text:
|
||||
return None
|
||||
m = _URL_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(0).rstrip('.,);]')
|
||||
if not _INTENT_RE.search(text):
|
||||
return None
|
||||
try:
|
||||
p = urlparse(url)
|
||||
if p.scheme not in ('http', 'https'):
|
||||
return None
|
||||
if _is_blocked_host(p.hostname):
|
||||
logger.warning('blocked host for url fetch: %s', p.hostname)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
if 'github.com' in (p.hostname or '').lower():
|
||||
body = await _github_text(url)
|
||||
if not body:
|
||||
body = await _fetch_text(url)
|
||||
else:
|
||||
body = await _fetch_text(url)
|
||||
except Exception as e:
|
||||
logger.warning('url fetch failed: %s', e)
|
||||
return None
|
||||
|
||||
if not body or not body.strip():
|
||||
return None
|
||||
return {'url': url, 'text': body[:_MAX_CHARS]}
|
||||
@@ -195,7 +195,7 @@ async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_
|
||||
from app.api.chat_utils import _proxy_for_url
|
||||
|
||||
skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
|
||||
kb_context = _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
|
||||
kb_context = await _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
|
||||
system_prompt, function_definitions = _build_system_prompt(
|
||||
skills=skills,
|
||||
agents=[agent],
|
||||
|
||||
@@ -18,7 +18,7 @@ from datetime import datetime
|
||||
logger = logging.getLogger("aiflow.pgvector")
|
||||
|
||||
# PostgreSQL 连接配置
|
||||
PG_HOST = os.environ.get("POSTGRES_HOST", "pgvector")
|
||||
PG_HOST = os.environ.get("POSTGRES_HOST", "qihu-aiflow-pgvector")
|
||||
PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
|
||||
PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
|
||||
PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
|
||||
@@ -29,6 +29,32 @@ EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconf
|
||||
EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
|
||||
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
|
||||
|
||||
# ---- 本地嵌入缓存(SQLite 持久化):破解"每次嵌入消耗外部 API 额度"的隐性成本障碍 ----
|
||||
import sqlite3, hashlib, json as _json
|
||||
_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
|
||||
def _embed_cache_get(text, model):
|
||||
try:
|
||||
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||
row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return _json.loads(row[0])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
def _embed_cache_put(text, model, vec):
|
||||
try:
|
||||
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||
conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 连接池
|
||||
_pg_pool = None
|
||||
_pool_lock = asyncio.Lock()
|
||||
@@ -105,9 +131,12 @@ async def get_embedding(text: str) -> Optional[List[float]]:
|
||||
logger.warning("No embedding API key configured")
|
||||
return None
|
||||
|
||||
cached = _embed_cache_get(text, EMBEDDING_MODEL)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with httpx.AsyncClient(timeout=30, trust_env=False) as client:
|
||||
resp = await client.post(
|
||||
f"{EMBEDDING_API_URL}/v1/embeddings",
|
||||
headers={
|
||||
@@ -122,6 +151,7 @@ async def get_embedding(text: str) -> Optional[List[float]]:
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
embedding = data["data"][0]["embedding"]
|
||||
_embed_cache_put(text, EMBEDDING_MODEL, embedding)
|
||||
return embedding
|
||||
else:
|
||||
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
|
||||
|
||||
@@ -81,6 +81,41 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
|
||||
"""
|
||||
|
||||
|
||||
def get_platform_knowledge() -> str:
|
||||
"""获取平台全功能知识库文本,注入到AI对话系统提示中"""
|
||||
return PLATFORM_KNOWLEDGE
|
||||
def get_platform_knowledge(db=None, user_id=None) -> str:
|
||||
"""获取平台全功能知识库文本,注入到AI对话系统提示中。
|
||||
|
||||
若传入 db,则动态追加当前平台已上线的官方技能目录
|
||||
(source=official 且 status=published),确保管理员/前台导入或下架技能后,
|
||||
AI 能及时感知并使用「平台自身已有内容」,而非停留在陈旧硬编码清单(见 #712)。
|
||||
任何异常均安全回退到静态 PLATFORM_KNOWLEDGE。
|
||||
"""
|
||||
if db is None:
|
||||
return PLATFORM_KNOWLEDGE
|
||||
try:
|
||||
from .models import MarketApp
|
||||
|
||||
apps = (
|
||||
db.query(MarketApp)
|
||||
.filter(MarketApp.source == "official", MarketApp.status == "published")
|
||||
.order_by(MarketApp.capability, MarketApp.name)
|
||||
.all()
|
||||
)
|
||||
if not apps:
|
||||
return PLATFORM_KNOWLEDGE
|
||||
|
||||
lines = [
|
||||
"",
|
||||
"## 平台已上线技能(官方,优先推荐)",
|
||||
"以下为平台当前已发布上线的技能/应用,回答用户相关问题时**优先推荐使用**这些平台能力:",
|
||||
]
|
||||
for a in apps:
|
||||
name = getattr(a, "name", None) or "未命名"
|
||||
cap = getattr(a, "capability", None) or getattr(a, "category", None) or "通用"
|
||||
desc = (getattr(a, "description", "") or "").strip().replace("\n", " ")
|
||||
if len(desc) > 60:
|
||||
desc = desc[:60] + "…"
|
||||
lines.append(f"- **{name}**({cap}): {desc}")
|
||||
return PLATFORM_KNOWLEDGE + "\n" + "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.warning(f"[platform_knowledge] 动态构建失败,回退静态知识库: {e}")
|
||||
return PLATFORM_KNOWLEDGE
|
||||
@@ -52,16 +52,22 @@ const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: strin
|
||||
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
// 业务场景分类:与前端「应用市场」保持一致的 14 类标准(前台分类为准)
|
||||
const CATEGORIES = [
|
||||
{ id: 'writing', label: '文字创作' },
|
||||
{ id: 'coding', label: '代码能力' },
|
||||
{ id: 'data_analysis', label: '数据分析' },
|
||||
{ id: 'qa', label: '知识问答' },
|
||||
{ id: 'automation', label: '流程自动化' },
|
||||
{ id: 'creativity', label: '创意生成' },
|
||||
{ id: 'communication', label: '沟通协作' },
|
||||
{ id: 'reasoning', label: '逻辑推理' },
|
||||
{ id: 'other', label: '其他' },
|
||||
{ id: 'design', label: '🎨 做设计' },
|
||||
{ id: 'frontend', label: '💻 写前端' },
|
||||
{ id: 'mobile', label: '📱 做移动端' },
|
||||
{ id: 'backend', label: '🧪 后端/全栈' },
|
||||
{ id: 'qa', label: '🧪 测试质量' },
|
||||
{ id: 'search', label: '🔍 搜索信息' },
|
||||
{ id: 'docs', label: '✍️ 写文档' },
|
||||
{ id: 'marketing', label: '📢 推广获客' },
|
||||
{ id: 'workflow', label: '🔄 工作流' },
|
||||
{ id: 'secops', label: '🛡️ 安全运维' },
|
||||
{ id: 'learning', label: '🎓 学习教学' },
|
||||
{ id: 'video', label: '🎬 视频多媒体' },
|
||||
{ id: 'collab', label: '🏢 办公协作' },
|
||||
{ id: 'ai', label: '🛠️ AI能力' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
@@ -457,6 +457,64 @@ const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '',
|
||||
);
|
||||
};
|
||||
|
||||
// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
|
||||
// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
|
||||
// 2.6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
|
||||
const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
|
||||
const [open, setOpen] = useState(autoExpand);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const touchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoExpand) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
touchedRef.current = false;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!touchedRef.current) setOpen(false);
|
||||
}, 2600);
|
||||
return () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [autoExpand]);
|
||||
|
||||
if (!steps || steps.length === 0) return null;
|
||||
|
||||
const toggle = () => {
|
||||
touchedRef.current = true;
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setOpen(o => !o);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className="ai-process" open={open}>
|
||||
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||||
<span className="ai-process-badge">🧠</span>
|
||||
思考 / 执行过程({steps.length} 步,{open ? '点击收起' : '点击展开'})
|
||||
</summary>
|
||||
<div className="ai-process-list">
|
||||
{steps.map((s: any, i: number) => (
|
||||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||||
<span className="ai-process-ico">
|
||||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||||
</span>
|
||||
<div className="ai-process-body">
|
||||
{s.type === 'thinking' && (<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>)}
|
||||
{s.type === 'skill' && (<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||
{s.type === 'mcp' && (<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Main Component =====
|
||||
const ChatPage: React.FC = () => {
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
@@ -470,6 +528,8 @@ const ChatPage: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
// 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
|
||||
const [freshMsgIds, setFreshMsgIds] = useState<Set<string>>(new Set());
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
||||
const [agents, setAgents] = useState<any[]>([]);
|
||||
const [skills, setSkills] = useState<any[]>([]);
|
||||
@@ -901,6 +961,11 @@ const ChatPage: React.FC = () => {
|
||||
return [...filtered, ...newMsgs];
|
||||
});
|
||||
|
||||
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||||
if (data.assistant_message) {
|
||||
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||||
}
|
||||
|
||||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||||
if (data.assistant_message?.generating) {
|
||||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||||
@@ -1174,6 +1239,9 @@ const ChatPage: React.FC = () => {
|
||||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||||
return (
|
||||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||||
{msg.role === 'assistant' && steps.length > 0 && (
|
||||
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||||
)}
|
||||
<div className={`message-bubble ${msg.role}`}>
|
||||
{clarify ? (
|
||||
<ClarifyCard
|
||||
@@ -1237,34 +1305,6 @@ const ChatPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'assistant' && steps.length > 0 && (
|
||||
<details className="ai-process" open={false}>
|
||||
<summary className="ai-process-summary">
|
||||
<span className="ai-process-badge">🧠</span>
|
||||
思考 / 执行过程({steps.length} 步,点击展开)
|
||||
</summary>
|
||||
<div className="ai-process-list">
|
||||
{steps.map((s: any, i: number) => (
|
||||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||||
<span className="ai-process-ico">
|
||||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||||
</span>
|
||||
<div className="ai-process-body">
|
||||
{s.type === 'thinking' && (
|
||||
<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>
|
||||
)}
|
||||
{s.type === 'skill' && (
|
||||
<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
||||
)}
|
||||
{s.type === 'mcp' && (
|
||||
<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<div className={`message-meta ${msg.role}`}>
|
||||
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
||||
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
||||
|
||||
@@ -1000,51 +1000,7 @@
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.evo-anim-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.evo-anim-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Baby sprout animation */
|
||||
.evo-skill-node.baby.anim-on .evo-node-dot {
|
||||
animation: baby-sprout 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.evo-skill-node.baby.anim-on {
|
||||
animation: baby-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes baby-sprout {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes baby-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3); }
|
||||
50% { box-shadow: 0 0 16px 4px rgba(245, 158, 11, 0); }
|
||||
}
|
||||
|
||||
/* Staging breathe animation */
|
||||
.evo-skill-node.staging.anim-on {
|
||||
animation: staging-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes staging-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.06); opacity: 1; }
|
||||
}
|
||||
/* 技能图谱动画已在 #717 中移除(边框光晕溢出且无实际用途) */
|
||||
|
||||
/* Toast */
|
||||
.evo-toast {
|
||||
@@ -1535,47 +1491,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== v2: Baby Sprout Animation (GAP-03) ===== */
|
||||
.evo-skill-node.state-baby.anim-on {
|
||||
animation: baby-sprout 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes baby-sprout {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 182, 193, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 0 20px 4px rgba(255, 182, 193, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Baby skill sprout icon */
|
||||
.evo-skill-node.state-baby.anim-on .evo-node-dot::after {
|
||||
content: '🌱';
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
animation: sprout-wiggle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sprout-wiggle {
|
||||
0%, 100% { transform: translateX(-50%) rotate(-5deg); }
|
||||
50% { transform: translateX(-50%) rotate(5deg); }
|
||||
}
|
||||
|
||||
/* Staging breathe animation */
|
||||
.evo-skill-node.state-staging.anim-on {
|
||||
animation: staging-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes staging-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.08); opacity: 1; }
|
||||
}
|
||||
/* ===== v2: 技能图谱动画已在 #717 中移除(边框溢出且无实际用途) ===== */
|
||||
|
||||
/* ===== v2: Toast messages ===== */
|
||||
.evo-toast {
|
||||
|
||||
@@ -62,9 +62,7 @@ const shareThemes = [
|
||||
// ==================== Panel A: Skill Tree ====================
|
||||
const SkillTreePanel: React.FC<{
|
||||
skills: EvolutionSkill[];
|
||||
animEnabled: boolean;
|
||||
onToggleAnim: () => void;
|
||||
}> = ({ skills, animEnabled, onToggleAnim }) => {
|
||||
}> = ({ skills }) => {
|
||||
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
@@ -136,14 +134,6 @@ const SkillTreePanel: React.FC<{
|
||||
{/* Publish toast */}
|
||||
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
|
||||
|
||||
{/* Anim Toggle */}
|
||||
<div className="evo-panel-toolbar">
|
||||
<label className="evo-anim-toggle">
|
||||
<input type="checkbox" checked={animEnabled} onChange={onToggleAnim} />
|
||||
<span>动画效果</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Skill detail modal */}
|
||||
{selectedSkill && !showVersionHistory && (
|
||||
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
|
||||
@@ -305,7 +295,7 @@ const SkillTreePanel: React.FC<{
|
||||
<React.Fragment key={skill.id}>
|
||||
{idx > 0 && <div className="evo-node-connector" />}
|
||||
<div
|
||||
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`}
|
||||
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredSkill(skill.id)}
|
||||
onMouseLeave={() => setHoveredSkill(null)}
|
||||
onClick={() => setSelectedSkill(skill)}
|
||||
@@ -944,22 +934,10 @@ const EvolutionDashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||
const [animEnabled, setAnimEnabled] = useState(() => {
|
||||
return localStorage.getItem('evo_anim_enabled') !== 'false';
|
||||
});
|
||||
|
||||
const totalSkills = skills.length;
|
||||
const mainCount = skills.filter(s => s.state === 'main').length;
|
||||
const babyCount = skills.filter(s => s.state === 'baby').length;
|
||||
|
||||
const toggleAnim = useCallback(() => {
|
||||
setAnimEnabled(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem('evo_anim_enabled', String(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleViewSkill = (skillName: string) => {
|
||||
const skill = skills.find(s => s.skill_name === skillName);
|
||||
if (skill) {
|
||||
@@ -1071,7 +1049,7 @@ const EvolutionDashboard: React.FC = () => {
|
||||
))}
|
||||
</aside>
|
||||
<main className="evo-main">
|
||||
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} animEnabled={animEnabled} onToggleAnim={toggleAnim} />}
|
||||
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} />}
|
||||
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
|
||||
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||
|
||||
@@ -1805,3 +1805,23 @@
|
||||
font-weight: 400;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* 官方内置技能徽标(见 #716:官方导入并发布的技能默认全局已安装) */
|
||||
.modal-official-badge,
|
||||
.market-card-official-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-official-badge {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -337,6 +337,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
||||
setInstalled(!!app.installed);
|
||||
}, [app.id, app.installed]);
|
||||
|
||||
// 官方导入并发布的技能默认全局已安装,前端展示为「官方内置」(见 #716)
|
||||
const isOfficialBuiltin = app.source === 'official' && app.status === 'published';
|
||||
|
||||
useEffect(() => {
|
||||
// 加载评论
|
||||
marketAPI.getReviews(app.id).then((res: any) => {
|
||||
@@ -463,7 +466,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
|
||||
<span className="modal-price">{app.price} 元/月</span>
|
||||
)}
|
||||
</div>
|
||||
{installed ? (
|
||||
{isOfficialBuiltin ? (
|
||||
<span className="modal-official-badge">官方内置</span>
|
||||
) : installed ? (
|
||||
<button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
@@ -1949,7 +1954,9 @@ const MarketPage: React.FC = () => {
|
||||
<div className="market-card-bottom">
|
||||
<StarRating rating={app.ratingAvg} count={app.reviewCount} />
|
||||
<div className="market-card-right">
|
||||
{app.installed ? (
|
||||
{app.source === 'official' && app.status === 'published' ? (
|
||||
<span className="market-card-official-badge">官方内置</span>
|
||||
) : app.installed ? (
|
||||
<button
|
||||
className="market-card-uninstall-btn"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
padding: var(--space-3xl) var(--space-4xl);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
/* 见 #714:FrontLayout 的 .front-main 裁剪了主内容区,
|
||||
此处让页面自身在可用高度内纵向滚动,避免内容被截断无法上下查阅 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
animation: fadeIn var(--transition-normal);
|
||||
}
|
||||
|
||||
@@ -329,6 +335,8 @@
|
||||
|
||||
.tkd-table-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
padding: var(--space-lg) var(--space-xl) var(--space-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
-- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
|
||||
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
|
||||
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
|
||||
-- 注意:当前业务场景标准 = 前台「应用市场」14 类 (MARKET_SCENES):
|
||||
-- design, frontend, mobile, backend, qa, search, docs, marketing,
|
||||
-- workflow, secops, learning, video, collab, ai
|
||||
-- 这些为合法取值,必须保留,不得置空(#707 修复:此前 allowlist 不含 14 类导致重启被清空)。
|
||||
UPDATE market_apps
|
||||
SET capability = NULL
|
||||
WHERE capability IS NOT NULL
|
||||
AND capability NOT IN (
|
||||
'design', 'frontend', 'mobile', 'backend', 'qa', 'search', 'docs', 'marketing',
|
||||
'workflow', 'secops', 'learning', 'video', 'collab', 'ai',
|
||||
'freelance_content', 'freelance_client', 'freelance_finance',
|
||||
'office_doc', 'office_data', 'office_email', 'office_pm',
|
||||
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',
|
||||
|
||||
Reference in new issue
Block a user