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:
QihuAI Design committed 2026-07-23 05:42:48 +08:00
1 parent 49f7503978
commit a08ec87b22
18 files changed
+458 -180

No files matched your search

+7 -1
View File
@@ -932,7 +932,13 @@ async def test_model_connection(
"n": 1, "n": 1,
"size": "1024x1024", "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) latency = int((time.time() - start) * 1000)
if resp.status_code == 200: if resp.status_code == 200:
+33 -12
View File
@@ -37,17 +37,31 @@ SKILL_STATES = {
"frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"}, "frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"},
} }
# 业务场景分类(与前端「应用市场」保持一致,14 类岗位/职能视角)
# 统一标准:以前台 MARKET_SCENES 为准,后台技能管理也按此 14 类展示与筛选。
SKILL_CATEGORIES = [ SKILL_CATEGORIES = [
{"id": "writing", "label": "文字创作"}, {"id": "design", "label": "🎨 做设计"},
{"id": "coding", "label": "代码能力"}, {"id": "frontend", "label": "💻 写前端"},
{"id": "data_analysis", "label": "数据分析"}, {"id": "mobile", "label": "📱 做移动端"},
{"id": "qa", "label": "知识问答"}, {"id": "backend", "label": "🧪 后端/全栈"},
{"id": "automation", "label": "流程自动化"}, {"id": "qa", "label": "🧪 测试质量"},
{"id": "creativity", "label": "创意生成"}, {"id": "search", "label": "🔍 搜索信息"},
{"id": "communication", "label": "沟通协作"}, {"id": "docs", "label": "✍️ 写文档"},
{"id": "reasoning", "label": "逻辑推理"}, {"id": "marketing", "label": "📢 推广获客"},
{"id": "other", "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) tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags)
except Exception: except Exception:
tags_list = [t.strip() for t in str(raw_tags).split(",")] 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 { return {
"id": app.id, "id": app.id,
"user_id": getattr(app, "publisher_id", None) or "system", "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)) or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword))
) )
if category: if category:
mkt_query = mkt_query.filter(MarketApp.category == category) # 分类筛选统一按业务场景(capability, 14 类)进行,与前端应用市场一致
mkt_query = mkt_query.filter(MarketApp.capability == category)
# 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby' # 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby'
if state == "main": if state == "main":
mkt_query = mkt_query.filter(MarketApp.status == "published") mkt_query = mkt_query.filter(MarketApp.status == "published")
@@ -901,7 +922,7 @@ async def get_skills_stats(
market_by_category = {} market_by_category = {}
try: try:
market_by_category_rows = db.execute( 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() ).fetchall()
market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows} market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows}
except Exception: except Exception:
+2 -2
View File
@@ -1121,7 +1121,7 @@ def _build_system_prompt(
all_mcp: list = None, all_mcp: list = None,
db: Session = None,
) -> tuple: ) -> tuple:
@@ -1195,7 +1195,7 @@ def _build_system_prompt(
pk = get_platform_knowledge() pk = get_platform_knowledge(db=db)
+10 -5
View File
@@ -1566,11 +1566,16 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
except Exception as e: except Exception as e:
logger.warning(f"[_generate_media_task] 后台生成异常: {e}", exc_info=True)
# 兜底回写:任何意外异常都不能让消息永久停在「⏳ 生成中…」
try:
logger.warning(f"[_generate_media_task] 后台生成异常: {e}") 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: finally:
+34 -3
View File
@@ -2100,7 +2100,7 @@ async def _send_message_core(
all_mcp=_list_enabled_mcp_servers(db), 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}) 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
+8 -2
View File
@@ -691,7 +691,12 @@ def list_apps(
"capability": app.capability or None, "capability": app.capability or None,
"category": app.category or None, "category": app.category or None,
"tags": app.tags 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", "version": app.version or "1.0.0",
"security_scan": app.security_scan, "security_scan": app.security_scan,
"verified": bool(app.verified), "verified": bool(app.verified),
@@ -756,7 +761,8 @@ def get_app(
"tags": app.tags or None, "tags": app.tags or None,
"created_at": str(app.created_at), "created_at": str(app.created_at),
"purchased": purchased, "purchased": purchased,
"installed": installed, # 官方导入并发布的技能默认全局已安装(见 #716)
"installed": installed or (app.source == "official" and app.status == "published"),
"version": app.version or "1.0.0", "version": app.version or "1.0.0",
"security_scan": app.security_scan, "security_scan": app.security_scan,
"verified": bool(app.verified), "verified": bool(app.verified),
+163
View File
@@ -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]}
+1 -1
View File
@@ -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 from app.api.chat_utils import _proxy_for_url
skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id) 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( system_prompt, function_definitions = _build_system_prompt(
skills=skills, skills=skills,
agents=[agent], agents=[agent],
+32 -2
View File
@@ -18,7 +18,7 @@ from datetime import datetime
logger = logging.getLogger("aiflow.pgvector") logger = logging.getLogger("aiflow.pgvector")
# PostgreSQL 连接配置 # 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_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
PG_USER = os.environ.get("POSTGRES_USER", "aiflow") PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026") 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_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5") 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 _pg_pool = None
_pool_lock = asyncio.Lock() _pool_lock = asyncio.Lock()
@@ -105,9 +131,12 @@ async def get_embedding(text: str) -> Optional[List[float]]:
logger.warning("No embedding API key configured") logger.warning("No embedding API key configured")
return None return None
cached = _embed_cache_get(text, EMBEDDING_MODEL)
if cached:
return cached
try: try:
import httpx 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( resp = await client.post(
f"{EMBEDDING_API_URL}/v1/embeddings", f"{EMBEDDING_API_URL}/v1/embeddings",
headers={ headers={
@@ -122,6 +151,7 @@ async def get_embedding(text: str) -> Optional[List[float]]:
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
embedding = data["data"][0]["embedding"] embedding = data["data"][0]["embedding"]
_embed_cache_put(text, EMBEDDING_MODEL, embedding)
return embedding return embedding
else: else:
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}") logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
+37 -2
View File
@@ -81,6 +81,41 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
""" """
def get_platform_knowledge() -> str: def get_platform_knowledge(db=None, user_id=None) -> str:
"""获取平台全功能知识库文本,注入到AI对话系统提示中""" """获取平台全功能知识库文本,注入到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 return PLATFORM_KNOWLEDGE
+15 -9
View File
@@ -52,16 +52,22 @@ const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: strin
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' }, frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
}; };
// 业务场景分类:与前端「应用市场」保持一致的 14 类标准(前台分类为准)
const CATEGORIES = [ const CATEGORIES = [
{ id: 'writing', label: '文字创作' }, { id: 'design', label: '🎨 做设计' },
{ id: 'coding', label: '代码能力' }, { id: 'frontend', label: '💻 写前端' },
{ id: 'data_analysis', label: '数据分析' }, { id: 'mobile', label: '📱 做移动端' },
{ id: 'qa', label: '知识问答' }, { id: 'backend', label: '🧪 后端/全栈' },
{ id: 'automation', label: '流程自动化' }, { id: 'qa', label: '🧪 测试质量' },
{ id: 'creativity', label: '创意生成' }, { id: 'search', label: '🔍 搜索信息' },
{ id: 'communication', label: '沟通协作' }, { id: 'docs', label: '✍️ 写文档' },
{ id: 'reasoning', label: '逻辑推理' }, { id: 'marketing', label: '📢 推广获客' },
{ id: 'other', label: '其他' }, { id: 'workflow', label: '🔄 工作流' },
{ id: 'secops', label: '🛡️ 安全运维' },
{ id: 'learning', label: '🎓 学习教学' },
{ id: 'video', label: '🎬 视频多媒体' },
{ id: 'collab', label: '🏢 办公协作' },
{ id: 'ai', label: '🛠️ AI能力' },
]; ];
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
+68 -28
View File
@@ -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 ===== // ===== Main Component =====
const ChatPage: React.FC = () => { const ChatPage: React.FC = () => {
const { id } = useParams<{ id?: string }>(); const { id } = useParams<{ id?: string }>();
@@ -470,6 +528,8 @@ const ChatPage: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loadingConversations, setLoadingConversations] = useState(true); const [loadingConversations, setLoadingConversations] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
// 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
const [freshMsgIds, setFreshMsgIds] = useState<Set<string>>(new Set());
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]); const [agents, setAgents] = useState<any[]>([]);
const [skills, setSkills] = useState<any[]>([]); const [skills, setSkills] = useState<any[]>([]);
@@ -901,6 +961,11 @@ const ChatPage: React.FC = () => {
return [...filtered, ...newMsgs]; return [...filtered, ...newMsgs];
}); });
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
if (data.assistant_message) {
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
}
// 媒体异步生成:启动轮询,待后端回写后自动刷新 // 媒体异步生成:启动轮询,待后端回写后自动刷新
if (data.assistant_message?.generating) { if (data.assistant_message?.generating) {
startMediaPolling(selectedConversation!.id, data.assistant_message.id); startMediaPolling(selectedConversation!.id, data.assistant_message.id);
@@ -1174,6 +1239,9 @@ const ChatPage: React.FC = () => {
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null; const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
return ( return (
<div key={msg.id} className={`message-row ${msg.role}`}> <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}`}> <div className={`message-bubble ${msg.role}`}>
{clarify ? ( {clarify ? (
<ClarifyCard <ClarifyCard
@@ -1237,34 +1305,6 @@ const ChatPage: React.FC = () => {
)} )}
</div> </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}`}> <div className={`message-meta ${msg.role}`}>
<span className="message-time">{formatTime(msg.createdAt)}</span> <span className="message-time">{formatTime(msg.createdAt)}</span>
<span className="message-tokens">{msg.tokenUsed} tokens</span> <span className="message-tokens">{msg.tokenUsed} tokens</span>
@@ -1000,51 +1000,7 @@
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.evo-anim-toggle { /* 技能图谱动画已在 #717 中移除(边框光晕溢出且无实际用途) */
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; }
}
/* Toast */ /* Toast */
.evo-toast { .evo-toast {
@@ -1535,47 +1491,7 @@
} }
} }
/* ===== v2: Baby Sprout Animation (GAP-03) ===== */ /* ===== v2: 技能图谱动画已在 #717 中移除(边框溢出且无实际用途) ===== */
.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: Toast messages ===== */ /* ===== v2: Toast messages ===== */
.evo-toast { .evo-toast {
@@ -62,9 +62,7 @@ const shareThemes = [
// ==================== Panel A: Skill Tree ==================== // ==================== Panel A: Skill Tree ====================
const SkillTreePanel: React.FC<{ const SkillTreePanel: React.FC<{
skills: EvolutionSkill[]; skills: EvolutionSkill[];
animEnabled: boolean; }> = ({ skills }) => {
onToggleAnim: () => void;
}> = ({ skills, animEnabled, onToggleAnim }) => {
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null); const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null); const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
const [showVersionHistory, setShowVersionHistory] = useState(false); const [showVersionHistory, setShowVersionHistory] = useState(false);
@@ -136,14 +134,6 @@ const SkillTreePanel: React.FC<{
{/* Publish toast */} {/* Publish toast */}
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>} {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 */} {/* Skill detail modal */}
{selectedSkill && !showVersionHistory && ( {selectedSkill && !showVersionHistory && (
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}> <div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
@@ -305,7 +295,7 @@ const SkillTreePanel: React.FC<{
<React.Fragment key={skill.id}> <React.Fragment key={skill.id}>
{idx > 0 && <div className="evo-node-connector" />} {idx > 0 && <div className="evo-node-connector" />}
<div <div
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`} className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredSkill(skill.id)} onMouseEnter={() => setHoveredSkill(skill.id)}
onMouseLeave={() => setHoveredSkill(null)} onMouseLeave={() => setHoveredSkill(null)}
onClick={() => setSelectedSkill(skill)} onClick={() => setSelectedSkill(skill)}
@@ -944,22 +934,10 @@ const EvolutionDashboard: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null); const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
const [animEnabled, setAnimEnabled] = useState(() => {
return localStorage.getItem('evo_anim_enabled') !== 'false';
});
const totalSkills = skills.length; const totalSkills = skills.length;
const mainCount = skills.filter(s => s.state === 'main').length; const mainCount = skills.filter(s => s.state === 'main').length;
const babyCount = skills.filter(s => s.state === 'baby').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 handleViewSkill = (skillName: string) => {
const skill = skills.find(s => s.skill_name === skillName); const skill = skills.find(s => s.skill_name === skillName);
if (skill) { if (skill) {
@@ -1071,7 +1049,7 @@ const EvolutionDashboard: React.FC = () => {
))} ))}
</aside> </aside>
<main className="evo-main"> <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 === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)} {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>)} {activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
+20
View File
@@ -1805,3 +1805,23 @@
font-weight: 400; font-weight: 400;
color: var(--color-text-tertiary); 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;
}
+9 -2
View File
@@ -337,6 +337,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
setInstalled(!!app.installed); setInstalled(!!app.installed);
}, [app.id, app.installed]); }, [app.id, app.installed]);
// 官方导入并发布的技能默认全局已安装,前端展示为「官方内置」(见 #716)
const isOfficialBuiltin = app.source === 'official' && app.status === 'published';
useEffect(() => { useEffect(() => {
// 加载评论 // 加载评论
marketAPI.getReviews(app.id).then((res: any) => { 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> <span className="modal-price">{app.price} 元/月</span>
)} )}
</div> </div>
{installed ? ( {isOfficialBuiltin ? (
<span className="modal-official-badge">官方内置</span>
) : installed ? (
<button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}> <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"> <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"/> <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"> <div className="market-card-bottom">
<StarRating rating={app.ratingAvg} count={app.reviewCount} /> <StarRating rating={app.ratingAvg} count={app.reviewCount} />
<div className="market-card-right"> <div className="market-card-right">
{app.installed ? ( {app.source === 'official' && app.status === 'published' ? (
<span className="market-card-official-badge">官方内置</span>
) : app.installed ? (
<button <button
className="market-card-uninstall-btn" className="market-card-uninstall-btn"
onClick={(e) => { onClick={(e) => {
@@ -3,6 +3,12 @@
padding: var(--space-3xl) var(--space-4xl); padding: var(--space-3xl) var(--space-4xl);
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
/* 见 #714:FrontLayout 的 .front-main 裁剪了主内容区,
此处让页面自身在可用高度内纵向滚动,避免内容被截断无法上下查阅 */
height: 100%;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
animation: fadeIn var(--transition-normal); animation: fadeIn var(--transition-normal);
} }
@@ -329,6 +335,8 @@
.tkd-table-scroll { .tkd-table-scroll {
overflow-x: auto; overflow-x: auto;
overflow-y: auto;
max-height: 60vh;
padding: var(--space-lg) var(--space-xl) var(--space-sm); 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 已不再使用, -- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。 -- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。 -- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
-- 注意:当前业务场景标准 = 前台「应用市场」14 类 (MARKET_SCENES):
-- design, frontend, mobile, backend, qa, search, docs, marketing,
-- workflow, secops, learning, video, collab, ai
-- 这些为合法取值,必须保留,不得置空(#707 修复:此前 allowlist 不含 14 类导致重启被清空)。
UPDATE market_apps UPDATE market_apps
SET capability = NULL SET capability = NULL
WHERE capability IS NOT NULL WHERE capability IS NOT NULL
AND capability NOT IN ( AND capability NOT IN (
'design', 'frontend', 'mobile', 'backend', 'qa', 'search', 'docs', 'marketing',
'workflow', 'secops', 'learning', 'video', 'collab', 'ai',
'freelance_content', 'freelance_client', 'freelance_finance', 'freelance_content', 'freelance_client', 'freelance_finance',
'office_doc', 'office_data', 'office_email', 'office_pm', 'office_doc', 'office_data', 'office_email', 'office_pm',
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home', 'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',