Compare commits

..
Author SHA1 Message Date
QihuAI Design d3d8d62b94 fix(media): repair JSON_CONTAINS auto-routing + reconcile stale backend tests
CI / gate (push) Has been cancelled
Real bug:
- capability_registry.resolve_auto_model and chat_media._build_media_route used
  `Column.op("JSON_CONTAINS")(col, val)`, but SQLAlchemy's op() accepts only ONE
  argument -> TypeError on every image/video/audio auto-route, silently failing
  media generation (caught as "后台生成异常"). Replaced with func.json_contains().

Test reconciliation (no production behavior change):
- tests/test_context_engine.py: tiktoken upgrade made the hardcoded len//2 token
  estimates wrong; relax to tokenizer-agnostic assertions and add
  structured_memory=None to the _Summary mock (code now branches on it).
- tests/test_regression_r1r2r3.py: R3 FakeModel lacked `name` (code reads m.name);
  also the assertion assumed a literal '超时' string, but the failover architecture
  catches model-level exceptions and writes a user-understandable error marker
  instead. Align the assertion to the real contract: a media failure/timeout must
  be written back as a user-understandable error message, never left as '生成中…'.

CI gate: 28 backend pytest + 15 frontend vitest all green.
2026-07-23 09:16:31 +08:00
QihuAI Design a08ec87b22 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).
2026-07-23 05:42:48 +08:00
QihuAI Design 49f7503978 fix(models): widen market_apps.skill_content to MEDIUMTEXT for large SKILL.md 2026-07-22 12:42:43 +08:00
QihuAI Design 6da8c3938e fix(admin/mcp): prevent unnecessary wrapping in table headers and badges 2026-07-22 08:54:03 +08:00
QihuAI Design 6d06f9165c feat(skills): auto-tag+classify existing 60 skills, auto-tag on import, 409 uniqueness check, add tags column to admin 2026-07-22 07:58:40 +08:00
qihu-dev c32eed39bd feat(chat): 收拢式AI思考/执行过程可视化 + 修复span(name)重复传参导致技能/MCP执行崩溃 2026-07-22 00:17:54 +08:00
QihuAI Design 46528eddbb fix(market): 补齐MarketApp ORM category/tags字段 + 硬化导入管道(空内容兜底生成SKILL.md + GitHub直链归一) 2026-07-21 20:25:55 +08:00
QihuAI Design 97125d7b65 T4: AnySearch 改为全局固定配置 + 修复外部服务弹窗 UI 2026-07-21 19:27:27 +08:00
QihuAI Design 3c9d9518b1 T4: AnySearch 联网检索接入 (anysearch_client trust_env直连 NO_PROXY, kb_router use_web RRF融合, chat_context 自动注入联网上下文, admin 支持 anysearch 服务类型, 新增 POST /api/knowledge/web-search 端点) 2026-07-21 18:29:51 +08:00
QihuAI Design be7d792dc2 T3: TreeSearch 结构化文档两级检索 (build_kb_tree + tree_search BM25+向量融合, 接入 kb_router 为 tree_chunk 源, 新增 GET /api/knowledge/tree-search) 2026-07-21 12:56:57 +08:00
qihu-dev e30e385f11 T2: 递归再压缩 + Semantica式结构化长期记忆层(结构化记忆列/抽取/注入) 2026-07-21 12:38:59 +08:00
qihu-dev 6890570142 T1: 修复 chat_context SyntaxError(/n 损坏) 并激活 pgvector 混合检索接入聊天KB 2026-07-21 12:21:06 +08:00
QihuAI Design 75f53a7e8a fix: 文生图/视频测试连接走媒体端点直连(_proxy_for_url 增加 images/videos 直连规则),绕过智能路由代理重置长连接导致的 Server disconnected;gpt-image-2/agnes 测试连接恢复。配合 models.py 的 1024x1024 size 与 chat_media.py 的 failover 路由(默认模型第一顺位)。 2026-07-20 22:26:30 +08:00
QihuAI Design 0b00d6da45 fix: 文生图连接测试 size 1024x1024 + 媒体端点智能推导 + 图像/视频模型 failover 路由(默认模型第一顺位) 2026-07-20 22:11:53 +08:00
QihuAI Design 7674729804 test(frontend): extract Markdown renderer to utils/markdown.ts + add vitest regression tests
CI / gate (push) Has been cancelled
2026-07-20 20:32:36 +08:00
QihuAI Design 11b80b90f4 fix(migrations): MariaDB 兼容幂等化 privacy_mode 列与 distributions 种子插入
CI / gate (push) Has been cancelled
2026-07-20 19:37:57 +08:00
QihuAI Design 71a6403e5b fix: 加宽 skill_trajectory_links.atom_id 列 VARCHAR(36)->128 修复 DataError(1406);TraceIdFilter 从 root logger 移至 handler 修复子 logger %(trace_id)s KeyError 日志刷屏;evolution scheduler 循环增加 db.rollback() 兜底;附带 models.py CRLF->LF 归一。
CI / gate (push) Has been cancelled
2026-07-20 19:02:40 +08:00
42 changed files with 8962 additions and 503 deletions

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
"""
AnySearch 联网检索客户端
======================
调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search),
为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。
约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用
trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。
失败一律返回空/降级结果,保证聊天链路不中断。
"""
import json
import logging
from typing import List, Dict, Optional
logger = logging.getLogger("aiflow.anysearch")
DEFAULT_API_URL = "https://api.anysearch.com/v1/search"
DEFAULT_ZONE = "cn"
REQUEST_TIMEOUT = 8.0
async def anysearch_search(
api_key: str,
api_url: str = DEFAULT_API_URL,
query: str = "",
top_k: int = 5,
zone: str = DEFAULT_ZONE,
freshness: Optional[str] = None,
timeout: float = REQUEST_TIMEOUT,
) -> List[Dict]:
"""调用 AnySearch 搜索,返回结构化结果列表。
每个结果:
{
"content": snippet 摘要文本,
"score": 0.9..0.5 递减,
"source": "web_anysearch",
"metadata": {"url", "title", "source_domain", "zone"}
}
失败时返回 [] (不抛异常)。
"""
if not api_key:
return []
try:
import httpx
except ImportError:
logger.warning("httpx not available for AnySearch")
return []
payload = {
"query": query,
"zone": zone,
"top_k": max(1, min(int(top_k), 20)),
}
if freshness:
payload["freshness"] = freshness
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
try:
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
resp = await client.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
return []
data = resp.json()
if data.get("code") != 0:
logger.warning(f"AnySearch biz error: {data.get('message')}")
return []
results = (data.get("data") or {}).get("results") or []
hits = []
for idx, r in enumerate(results[:top_k]):
title = r.get("title", "") or ""
url = r.get("url", "") or ""
snippet = r.get("snippet") or r.get("content") or ""
if not snippet and not title:
continue
try:
from urllib.parse import urlparse
domain = urlparse(url).netloc
except Exception:
domain = ""
hits.append({
"content": snippet,
"score": max(0.4, 0.9 - idx * 0.08),
"source": "web_anysearch",
"metadata": {
"url": url,
"title": title,
"source_domain": domain,
"zone": zone,
},
})
return hits
except Exception as e:
logger.warning(f"AnySearch search failed: {e}")
return []
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
if not api_key:
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
try:
import httpx
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
resp = await client.post(
api_url,
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
)
if resp.status_code == 200:
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
except Exception as e:
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}
def load_anysearch_config(db_session) -> Dict:
"""从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。"""
if db_session is None:
return {}
try:
from app.models import SiteSetting
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
if not row or not row.value:
return {}
cfg = json.loads(row.value)
if not isinstance(cfg, dict):
return {}
return cfg
except Exception as e:
logger.warning(f"load_anysearch_config failed: {e}")
return {}
def save_anysearch_config(
db_session,
api_key: str,
is_enabled: bool = True,
api_url: str = DEFAULT_API_URL,
zone: str = DEFAULT_ZONE,
freshness: Optional[str] = None,
top_k: int = 5,
) -> bool:
"""保存/更新全局 AnySearch 配置到 site_settings。"""
if db_session is None:
return False
try:
from app.models import SiteSetting
from datetime import datetime
cfg = {
"api_key": api_key.strip(),
"is_enabled": bool(is_enabled),
"api_url": api_url.strip() or DEFAULT_API_URL,
"zone": zone or DEFAULT_ZONE,
"freshness": freshness,
"top_k": max(1, min(int(top_k), 20)),
}
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
if row:
row.value = json.dumps(cfg, ensure_ascii=False)
row.updated_at = datetime.now()
else:
from app.models import gen_uuid
row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False))
db_session.add(row)
db_session.commit()
return True
except Exception as e:
logger.warning(f"save_anysearch_config failed: {e}")
try:
db_session.rollback()
except Exception:
pass
return False
@@ -38,6 +38,11 @@ class ExternalServiceConfig(BaseModel):
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
class AnySearchConfig(BaseModel):
api_key: str = Field(min_length=1, max_length=500)
is_enabled: bool = True
class ExternalServiceUpdate(BaseModel): class ExternalServiceUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
api_url: Optional[str] = None api_url: Optional[str] = None
@@ -738,8 +743,8 @@ def _get_external_services_table(db: Session):
return [] return []
def _check_service_connection(service_type: str, api_url: str, api_key: str) -> dict: async def _check_service_connection(service_type: str, api_url: str, api_key: str) -> dict:
"""测试外部知识库服务连接状态(当前仅支持 Dify)""" """测试外部知识库服务连接状态(支持 Dify)"""
try: try:
if service_type == "dify": if service_type == "dify":
from ...dify_kb import dify_test from ...dify_kb import dify_test
@@ -793,13 +798,13 @@ async def create_external_service(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""创建新的外部知识库服务连接配置""" """创建新的外部知识库服务连接配置"""
# 验证服务类型(当前仅支持 Dify) # 验证服务类型(当前仅支持 Dify;AnySearch 已改为全局固定配置)
valid_types = ["dify"] valid_types = ["dify"]
if req.service_type not in valid_types: if req.service_type not in valid_types:
raise HTTPException(status_code=400, detail=f"不支持的服务类型: {req.service_type}") raise HTTPException(status_code=400, detail=f"不支持的服务类型: {req.service_type}")
# 检查连接 # 检查连接
conn_result = _check_service_connection(req.service_type, req.api_url, req.api_key or "") conn_result = await _check_service_connection(req.service_type, req.api_url, req.api_key or "")
service_id = str(uuid.uuid4()) service_id = str(uuid.uuid4())
config_json = json.dumps(req.config or {}, ensure_ascii=False) config_json = json.dumps(req.config or {}, ensure_ascii=False)
@@ -877,6 +882,54 @@ async def create_external_service(
} }
@router.get("/anysearch/config", summary="获取 AnySearch 全局配置")
async def get_anysearch_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""读取全局 AnySearch 联网检索配置(site_settings 表)。"""
from ...anysearch_client import load_anysearch_config, DEFAULT_API_URL
cfg = load_anysearch_config(db)
return {
"code": 0,
"data": {
"api_key": cfg.get("api_key", ""),
"is_enabled": cfg.get("is_enabled", False),
"api_url": cfg.get("api_url") or DEFAULT_API_URL,
"zone": cfg.get("zone", "cn"),
"freshness": cfg.get("freshness"),
"top_k": cfg.get("top_k", 5),
},
}
@router.post("/anysearch/config", summary="保存 AnySearch 全局配置")
async def save_anysearch_config_endpoint(
req: AnySearchConfig,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""保存全局 AnySearch 联网检索配置,并测试连通性。"""
from ...anysearch_client import save_anysearch_config, anysearch_health, DEFAULT_API_URL
ok = save_anysearch_config(db, api_key=req.api_key, is_enabled=req.is_enabled, api_url=DEFAULT_API_URL)
if not ok:
raise HTTPException(status_code=500, detail="保存 AnySearch 配置失败")
health = await anysearch_health(req.api_key or "", DEFAULT_API_URL)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_anysearch_config",
target_type="site_setting",
target_id="anysearch_config",
detail=f"更新 AnySearch 全局配置: enabled={req.is_enabled}, connected={health.get('connected')}",
)
return {
"code": 0,
"message": "AnySearch 配置已保存",
"data": {"connection": health},
}
@router.put("/external/services/{service_id}", summary="更新外部知识库服务配置") @router.put("/external/services/{service_id}", summary="更新外部知识库服务配置")
async def update_external_service( async def update_external_service(
service_id: str, service_id: str,
@@ -916,7 +969,7 @@ async def update_external_service(
new_key = req.api_key or row[4] new_key = req.api_key or row[4]
if req.api_url or req.api_key: if req.api_url or req.api_key:
service_type = row[2] service_type = row[2]
conn_result = _check_service_connection(service_type, new_url, new_key) conn_result = await _check_service_connection(service_type, new_url, new_key)
updates["status"] = conn_result.get("status", "disconnected") updates["status"] = conn_result.get("status", "disconnected")
updates["updated_at"] = "NOW()" updates["updated_at"] = "NOW()"
@@ -999,7 +1052,7 @@ async def check_external_service_status(
if not row: if not row:
raise HTTPException(status_code=404, detail="外部知识库服务不存在") raise HTTPException(status_code=404, detail="外部知识库服务不存在")
conn_result = _check_service_connection(row[0], row[1], row[2]) conn_result = await _check_service_connection(row[0], row[1], row[2])
# 更新状态 # 更新状态
try: try:
+11 -2
View File
@@ -869,6 +869,9 @@ async def test_model_connection(
model_type = _detect_model_type(ai_model.capabilities) model_type = _detect_model_type(ai_model.capabilities)
timeout = ai_model.timeout or 60 timeout = ai_model.timeout or 60
# 图像生成实际会出图,单张耗时较长;连接测试需给足超时,否则会误报"失败"
if model_type == "image":
timeout = max(min(ai_model.timeout or 60, 200), 120)
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -927,9 +930,15 @@ async def test_model_connection(
"model": ai_model.name, "model": ai_model.name,
"prompt": "a red circle", "prompt": "a red circle",
"n": 1, "n": 1,
"size": "256x256", "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:
File diff suppressed because it is too large. Load diff
+116 -39
View File
@@ -839,6 +839,86 @@ async def _agnes_post_json(api_url: str, headers: dict, payload: dict, timeout)
return None, last_err return None, last_err
def _media_api_url(ai_model, provider, media_kind: str) -> str:
"""推导图像/视频生成地址,避免把 chat/completions 端点误用作媒体端点。
- 若模型 endpoint 已是对应的媒体端点(含 /images/generations 或 /videos),直接使用;
- 否则从 chat endpoint 反推 base,再拼 /v1/images|videos/generations;
- 若 endpoint 为空,则回退供应商 api_base_url + 媒体路径。
"""
suffix = "/v1/images/generations" if media_kind == "image" else "/v1/videos"
chat_suffixes = ["/v1/chat/completions", "/chat/completions"]
ep = (ai_model.endpoint or "").strip()
if ep:
if suffix in ep:
return ep
base = ep
for cs in chat_suffixes:
if base.endswith(cs):
base = base[: -len(cs)]
break
else:
if "/v1" in base:
base = base.rsplit("/v1", 1)[0]
return f"{base.rstrip('/')}{suffix}"
if provider and provider.api_base_url:
return f"{provider.api_base_url.rstrip('/')}{suffix}"
return ""
def _build_media_route(db, capability: str, preferred_id: str = None):
"""构造媒体生成模型路由顺序:
1) 按能力默认模型(后台"能力默认模型"或全局默认) 作为第一顺位
2) 调用方显式指定的模型(若不同于默认) 次位
3) 其余启用且具备该能力的模型,按 is_default 降序、created_at 升序 排列
去重后返回 AIModel 列表。
"""
from ..models import AIModel as _AM
selected = []
seen = set()
def _add(m):
if m and m.id not in seen:
selected.append(m)
seen.add(m.id)
default_model = resolve_auto_model(capability, db)
if default_model:
_add(default_model)
if preferred_id:
pref = db.query(_AM).filter(_AM.id == preferred_id, _AM.is_enabled == True).first()
_add(pref)
others = db.query(_AM).filter(
_AM.is_enabled == True,
func.json_contains(_AM.capabilities, f'"{capability}"'),
).order_by(_AM.is_default.desc(), _AM.created_at.asc()).all()
for m in others:
_add(m)
return selected
async def _run_media_route(db, route, prompt, user_id, media_kind):
"""依次尝试路由中的模型生成媒体;首个成功(非 ⚠️ 开头)即返回,否则尝试下一个。"""
last_err = None
for idx, m in enumerate(route, 1):
logger.info(f"[_run_media_route] ({idx}/{len(route)}) 尝试模型 {m.name} ({m.id})")
try:
res = await _generate_media(db, m, prompt, user_id)
except Exception as e:
last_err = f"模型 {m.name} 异常: {str(e)[:160]}"
logger.warning(f"[_run_media_route] 模型 {m.name} 异常: {e}")
continue
if res and not res.startswith("⚠️"):
return res
last_err = res
logger.warning(f"[_run_media_route] 模型 {m.name} 失败: {str(res)[:160]}")
return last_err or "⚠️ 所有模型均生成失败"
async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str: async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str:
@@ -880,6 +960,9 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
timeout = ai_model.timeout or 120 timeout = ai_model.timeout or 120
# 单次媒体调用超时封顶:避免单个供应商长时间挂起拖垮整条生成链路;
# 多模型 failover 由 _generate_media_task 外层循环保证
call_timeout = min(timeout, 300 if "video" in caps else 180)
@@ -895,15 +978,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_url = ai_model.endpoint api_url = _media_api_url(ai_model, provider, "image")
if not api_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/images/generations" if provider else ""
@@ -951,7 +1026,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
"size": "1024x768", "size": "1024x1024",
@@ -960,7 +1035,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
async with span("media.agnes_call"): async with span("media.agnes_call"):
resp, err = await _agnes_post_json(api_url, headers, payload, timeout) resp, err = await _agnes_post_json(api_url, headers, payload, call_timeout)
@@ -992,7 +1067,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
resp2, _ = await _agnes_post_json(api_url, headers, payload, timeout) resp2, _ = await _agnes_post_json(api_url, headers, payload, call_timeout)
@@ -1092,15 +1167,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_url = ai_model.endpoint api_url = _media_api_url(ai_model, provider, "video")
if not api_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/videos" if provider else ""
@@ -1169,7 +1236,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
async with span("media.agnes_call"): async with span("media.agnes_call"):
resp, err = await _agnes_post_json(api_url, headers, payload, timeout) resp, err = await _agnes_post_json(api_url, headers, payload, call_timeout)
@@ -1197,7 +1264,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
resp2, _ = await _agnes_post_json(api_url, headers, payload, timeout) resp2, _ = await _agnes_post_json(api_url, headers, payload, call_timeout)
@@ -1430,22 +1497,27 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
logger.info(f"[_generate_media_task] 启动 media 生成 msg={ai_msg_id} model={ai_model_id} prompt={prompt[:60]!r}") logger.info(f"[_generate_media_task] 启动 media 生成 msg={ai_msg_id} model={ai_model_id} prompt={prompt[:60]!r}")
caps = [c.lower() for c in (ai_model.capabilities or [])] caps = [c.lower() for c in (ai_model.capabilities or [])]
is_video = "video" in caps is_video = "video" in caps
timeout_seconds = 300 if is_video else 180 media_kind = "video" if is_video else "image"
# 构造模型路由:默认模型第一顺位,之后按路由策略逐一尝试
route = _build_media_route(db, media_kind, ai_model_id)
if not route:
result = f"⚠️ 未找到可用的{'视频' if is_video else '图像'}模型,请在后台「大模型库」中启用具备该能力的模型。"
else:
_media_name = "视频" if is_video else "图像"
per_call = 300 if is_video else 180
route_timeout = min(per_call * len(route) + 30, 900 if is_video else 600)
try: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
_generate_media(db, ai_model, prompt, user_id), _run_media_route(db, route, prompt, user_id, media_kind),
timeout=timeout_seconds timeout=route_timeout,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning(f"[_generate_media_task] media 生成超时 msg={ai_msg_id} is_video={is_video} timeout={timeout_seconds}s") logger.warning(f"[_generate_media_task] media 路由全部超时 msg={ai_msg_id} is_video={is_video} timeout={route_timeout}s")
_media_name = "视频" if is_video else "图像" result = f"⚠️ {_media_name}生成超时:已依次尝试 {len(route)} 个模型仍失败,请稍后重试。"
result = f"⚠️ 媒体生成超时:{_media_name}生成耗时超过 {timeout_seconds} 秒,请稍后重试。"
except Exception as e: except Exception as e:
logger.warning(f"[_generate_media_task] media 生成异常 msg={ai_msg_id}: {e}", exc_info=True) logger.warning(f"[_generate_media_task] media 路由异常 msg={ai_msg_id}: {e}", exc_info=True)
result = f"⚠️ 媒体生成失败:{str(e)[:200]}" result = f"⚠️ {_media_name}生成失败:{str(e)[:200]}"
@@ -1494,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:
File diff suppressed because it is too large. Load diff
+9
View File
@@ -147,6 +147,15 @@ def _proxy_for_url(url: str) -> str | None:
# 文生图 / 视频生成走直连:智能路由代理(172.18.0.1:7890)与 HK 代理对这类长耗时、
# 大响应体的生成连接会偶发重置(Server disconnected),而各图像/视频供应商均为公网
# API,直连更稳定(已实测 gpt-image-2 / agnes 直连均返回 200)。故媒体端点统一直连。
_lower = (url or "").lower()
if "/images/" in _lower or "/videos/" in _lower or "/v1/images" in _lower or "/v1/videos" in _lower:
return None
if "agnes-ai.com" in host or "agnes-ai.space" in host: if "agnes-ai.com" in host or "agnes-ai.space" in host:
+99
View File
@@ -1,3 +1,4 @@
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc from sqlalchemy import and_, desc, asc
@@ -851,6 +852,15 @@ def get_documents(
@router.post("/bases/{base_id}/documents") @router.post("/bases/{base_id}/documents")
async def _index_kb_doc_to_pgvector(doc_id: str, kb_id: str, content: str):
"""将文档内容异步索引到 pgvector,供语义向量检索使用(best-effort)。"""
try:
from app.pgvector_search import index_document
await index_document(doc_id, kb_id, content, metadata={"source": "kb_upload"})
except Exception as e:
logger.warning(f"[kb] 向量索引失败 doc={doc_id}: {e}")
async def upload_document( async def upload_document(
base_id: str, base_id: str,
file: Optional[UploadFile] = File(None), file: Optional[UploadFile] = File(None),
@@ -961,6 +971,12 @@ async def upload_document(
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
# T1: 异步索引到 pgvector 语义库(best-effort,不阻塞上传响应)
try:
asyncio.create_task(_index_kb_doc_to_pgvector(doc.id, base_id, content))
except Exception:
pass
LogService.log_operation( LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="upload", user_id=current_user.id, username=current_user.username, action="upload",
target="knowledge_document", target_id=doc.id, target="knowledge_document", target_id=doc.id,
@@ -1415,6 +1431,89 @@ def search_knowledge(
} }
@router.get("/tree-search")
async def tree_search_endpoint(
query: str = Query(min_length=1, description="检索查询"),
kb_id: Optional[str] = Query(default=None, description="限定知识库ID"),
scope: Optional[str] = Query(
default=None,
description="作用域: doc:<doc_id> | dir:<dir_id> | base:<base_id>",
),
top_k: int = Query(default=5, ge=1, le=20),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""TreeSearch 结构化文档两级检索。
Level 1: 返回用户可见的知识库 -> 目录 -> 文档结构树(tree)。
Level 2: 在作用域内做分片段(chunk)级精准检索,命中带完整面包屑与片段摘要。
"""
from app.tree_search import build_kb_tree, tree_search
# Level 1: 结构树
tree = build_kb_tree(db, user_id=current_user.id, kb_id=kb_id)
# Level 2: 分片段检索
scope_doc = scope_dir = scope_base = None
if scope:
if scope.startswith("doc:"):
scope_doc = scope[len("doc:"):]
elif scope.startswith("dir:"):
scope_dir = scope[len("dir:"):]
elif scope.startswith("base:"):
scope_base = scope[len("base:"):]
hits = await tree_search(
db, query,
user_id=current_user.id,
kb_ids=[kb_id] if kb_id else None,
scope_base=scope_base, scope_dir=scope_dir, scope_doc=scope_doc,
top_k=top_k, mode="hybrid",
)
return {
"code": 0,
"data": {
"query": query,
"tree": tree,
"hits": hits,
"total": len(hits),
},
"message": "ok",
}
class WebSearchRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
top_k: int = 5
@router.post("/web-search", summary="AnySearch 联网检索")
async def web_search_endpoint(
req: WebSearchRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""联网检索(AnySearch):返回实时网页结果,供前端 / 智能体引用。"""
try:
from ..anysearch_client import anysearch_search, load_anysearch_config
cfg = load_anysearch_config(db)
if not cfg.get("is_enabled") or not cfg.get("api_key"):
return {"code": 0, "data": {"results": [], "enabled": False, "message": "未配置 AnySearch 联网检索服务"}}
hits = await anysearch_search(
cfg["api_key"],
cfg.get("api_url") or "https://api.anysearch.com/v1/search",
req.query,
top_k=req.top_k,
zone=cfg.get("zone", "cn"),
freshness=cfg.get("freshness"),
)
return {"code": 0, "data": {"results": hits, "enabled": True}}
except Exception as e:
logger.warning(f"web-search endpoint failed: {e}")
return {"code": 1, "message": str(e), "data": {"results": []}}
def generate_document_excerpt(doc, query: str) -> str: def generate_document_excerpt(doc, query: str) -> str:
"""根据文档类型生成模拟的原文摘录""" """根据文档类型生成模拟的原文摘录"""
excerpts = { excerpts = {
+285 -19
View File
@@ -40,24 +40,41 @@ def _skill_http_proxy():
def _sanitize_skill_content(text: str) -> str: def _sanitize_skill_content(text: str) -> str:
"""仅当内容疑似有效 SKILL.md 时才接受,避免把无关网页/乱码写进 skill_content。""" """接受疑似有效 SKILL.md / markdown 规范的内容;仅拒绝整页 HTML 源码。
注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法
SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且
携带 markdown/skill 标记即接受。
"""
if not text: if not text:
return "" return ""
t = text.strip() t = text.strip()
if len(t) < 50: if len(t) < 20:
return "" return ""
low = t.lower() low = t.lower()
# 拒绝整页 HTML(避免把网页源码当技能内容写库) # 拒绝整页 HTML 文档(避免把网页源码当技能内容写库)
if low.startswith("<!doctype") or low.startswith("<html") or "<script" in low or t.count("<div") > 3: if low.startswith("<!doctype") or low.startswith("<html"):
return "" return ""
# 接受:YAML front matter 或 含 markdown 标题(# / ##) # 仅当明显是渲染后的 HTML 页面(含 script 且多 div 块)才拒绝;允许含 <script> 示例的 markdown
has_front = t.startswith("---") and ("name:" in t or "description:" in t) if "<script" in low and t.count("<div") > 3:
has_heading = t.startswith("#") or "\n#" in t or "##" in t return ""
if not (has_front or has_heading): # 接受携带任一 markdown / skill 标记的内容
markers = ("#", "---", "name:", "description:", "```", "##")
if not any(m in t for m in markers):
return "" return ""
return t return t
def _normalize_github_url(url: str) -> str:
"""GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。"""
u = (url or "").strip()
if "github.com" in u and ("/blob/" in u or "/tree/" in u):
m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u)
m = m.replace("/blob/", "/").replace("/tree/", "/")
return m
return u
def _extract_skill_from_html(html: str, base_url: str) -> str: def _extract_skill_from_html(html: str, base_url: str) -> str:
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。""" """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
proxy = _skill_http_proxy() proxy = _skill_http_proxy()
@@ -72,13 +89,23 @@ def _extract_skill_from_html(html: str, base_url: str) -> str:
txt = re.sub(r"<[^>]+>", "", b) txt = re.sub(r"<[^>]+>", "", b)
if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80: if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
return txt.strip() return txt.strip()
# 3) 跳转到 raw SKILL.md 链接(最多试 3 个) # 3) 跳转到 raw SKILL.md 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换)
links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE) links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
for link in links[:3]: seen = set()
for link in links[:5]:
if link in seen:
continue
seen.add(link)
try: try:
abs_url = urljoin(base_url, link) abs_url = urljoin(base_url, link)
# GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent
if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url:
abs_url = abs_url.replace("/blob/", "/raw/")
elif "github.com" in abs_url and "/blob/" in abs_url:
abs_url = _normalize_github_url(abs_url)
fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "")
with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c: with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
rr = c.get(abs_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"}) rr = c.get(fetch_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if rr.status_code == 200 and len(rr.text) > 80: if rr.status_code == 200 and len(rr.text) > 80:
return rr.text.strip() return rr.text.strip()
except Exception: except Exception:
@@ -90,6 +117,8 @@ def _fetch_skill_content(url: str) -> str:
"""抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。""" """抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
if not url or not url.startswith("http"): if not url or not url.startswith("http"):
return "" return ""
# GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一)
url = _normalize_github_url(url)
proxy = _skill_http_proxy() proxy = _skill_http_proxy()
try: try:
with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client: with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
@@ -106,6 +135,206 @@ def _fetch_skill_content(url: str) -> str:
return "" return ""
def _category_guidance(cat: Optional[str]) -> str:
"""按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。"""
guides = {
"writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点",
"coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法",
"data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向",
"qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清",
"automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板",
"creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案",
"communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步",
"reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出",
}
return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案")
def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str:
"""当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md,
确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。"""
desc = (description or "").strip() or f"{name} 相关能力"
cat = category or "other"
cat_label_map = {
"writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析",
"qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成",
"communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力",
}
cat_label = cat_label_map.get(cat, cat)
guidance = _category_guidance(cat)
return (
f"---\n"
f"name: {name}\n"
f"description: {desc}\n"
f"category: {cat}\n"
f"---\n\n"
f"# {name}\n\n"
f"{desc}\n\n"
f"## 适用场景\n"
f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n"
f"## 执行指引\n"
f"{guidance}\n\n"
f"## 使用建议\n"
f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
f"- 输出结构清晰、可直接使用。\n"
f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
)
# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) =====
# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填,
# 也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。
# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。
_TAG_KEYWORDS = {
# 前端框架/库
"react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue",
"angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript",
"javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui",
"remotion": "Remotion", "expo": "Expo", "flutter": "Flutter",
"react native": "React Native", "vite": "Vite", "vercel": "Vercel",
# 后端/数据库
"node": "Node.js", "python": "Python", "postgres": "PostgreSQL",
"postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso",
"duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle",
"orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API",
"azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s",
"devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo",
# 测试/质量
"playwright": "Playwright", "cypress": "Cypress", "test": "测试",
"测试": "测试", "tdd": "TDD", "e2e": "E2E",
# 设计/视觉
"设计": "设计", "design": "设计", "ui": "UI", "ux": "UX",
"视觉": "视觉", "前端设计": "前端设计", "交互": "交互",
"图片生成": "图片生成", "ppt": "PPT", "排版": "排版",
# 写作/文档
"写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF",
"ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化",
# 营销/沟通
"营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件",
"email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容",
"策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM",
"上市": "上市", "心理学": "心理学",
# 推理/思维
"推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证",
"质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO",
# 自动化/流程
"自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程",
"发现": "技能发现", "执行计划": "执行计划",
# 数据
"数据分析": "数据分析", "分析": "数据分析",
# 内容生成/创意
"头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简",
# 代码相关
"代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构",
"组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模",
"抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现",
"数据获取": "数据获取", "移动端": "移动端", "移动": "移动端",
"全栈": "全栈", "后端": "后端", "前端": "前端",
}
# 分类 -> 中文基础标签(每个技能至少带一个分类标签)
_CATEGORY_TAG = {
"writing": "写作", "coding": "编程", "data_analysis": "数据分析",
"qa": "问答", "automation": "自动化", "creativity": "创意",
"communication": "沟通", "reasoning": "推理", "other": "通用",
}
# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确
_CATEGORY_NAME_OVERRIDES = {
"typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding",
"expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding",
"原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding",
"supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding",
"turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding",
"remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding",
"turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding",
"azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding",
"web 应用测试": "coding", "领域建模": "coding",
"web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity",
"高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity",
"极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity",
"移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity",
"前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity",
"prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing",
"写计划": "writing", "word 文档": "writing",
"营销心理学": "communication", "上市策略": "communication", "内容策略": "communication",
"社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication",
"想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning",
"完成前验证": "reasoning",
"技能发现": "automation", "playwright cli": "automation", "执行计划": "automation",
"duckdb 查询": "data_analysis",
}
# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级
_CATEGORY_KEYWORDS = {
"coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript",
"javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm",
"docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo",
"react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd",
"领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite",
"drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取",
"设计图转代码", "前端实现"],
"creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt",
"重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"],
"writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"],
"communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略",
"推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"],
"reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"],
"automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"],
"data_analysis": ["数据分析", "分析", "数据"],
"qa": ["问答", "知识问答", "搜索", "疑问"],
}
def _kw_hit(text: str, kw: str) -> bool:
"""关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。"""
if not kw:
return False
if kw.isascii():
return bool(re.search(r"(?<![a-z0-9])" + re.escape(kw) + r"(?![a-z0-9])", text))
return kw in text
def _auto_category(name: Optional[str], description: Optional[str] = None,
content: Optional[str] = None) -> str:
"""离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。"""
norm_name = (name or "").strip().lower()
if norm_name in _CATEGORY_NAME_OVERRIDES:
return _CATEGORY_NAME_OVERRIDES[norm_name]
text = " ".join([name or "", description or "", content or ""]).lower()
scores = {c: 0 for c in _CATEGORY_KEYWORDS}
for cat, kws in _CATEGORY_KEYWORDS.items():
for kw in kws:
if _kw_hit(text, kw):
scores[cat] += 1
best = max(scores, key=lambda c: scores[c])
return best if scores[best] > 0 else "other"
def _auto_tags(name: Optional[str], description: Optional[str] = None,
content: Optional[str] = None, category: Optional[str] = None) -> str:
"""离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。"""
text = " ".join([name or "", description or "", content or ""]).lower()
tags: list = []
seen = set()
# 1) 分类基础标签
if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen:
tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category])
# 2) 关键词派生标签
for kw, tag in _TAG_KEYWORDS.items():
if tag in seen:
continue
if _kw_hit(text, kw):
tags.append(tag); seen.add(tag)
return ",".join(tags[:6])
def _norm_name(name: Optional[str]) -> str:
"""技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。"""
return re.sub(r"\s+", " ", (name or "").strip().lower())
# ===== Agent 接入前连通性 / 鉴权校验 ===== # ===== Agent 接入前连通性 / 鉴权校验 =====
def _normalize_dify_url(endpoint: str) -> str: def _normalize_dify_url(endpoint: str) -> str:
ep = endpoint.rstrip("/") ep = endpoint.rstrip("/")
@@ -321,10 +550,16 @@ def detect_capability(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""导入前 AI 验证阶段:根据名称/描述/内容识别业务场景分类,供前台预填并允许用户修改。""" """导入前识别阶段:AI 识别业务场景(capability),离线规则法自动归类分类 + 自动打标签,
供前台预填并允许用户修改。"""
cap = _classify_scene(req.name, req.description, req.skill_content, db) cap = _classify_scene(req.name, req.description, req.skill_content, db)
label = _scene_label(cap) label = _scene_label(cap)
return {"code": 0, "data": {"capability": cap, "label": label}, "message": "ok"} category = _auto_category(req.name, req.description, req.skill_content)
tags = _auto_tags(req.name, req.description, req.skill_content, category)
return {"code": 0, "data": {
"capability": cap, "label": label,
"category": category, "tags": tags,
}, "message": "ok"}
@router.get("/ads") @router.get("/ads")
@@ -454,7 +689,14 @@ def list_apps(
"review_count": app.review_count or 0, "review_count": app.review_count or 0,
"created_at": str(app.created_at) if app.created_at else None, "created_at": str(app.created_at) if app.created_at else None,
"capability": app.capability or None, "capability": app.capability or None,
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False, "category": app.category or None,
"tags": app.tags or None,
# 官方导入并发布的技能默认全局已安装(见 #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),
@@ -515,9 +757,12 @@ def get_app(
"download_url": getattr(app, "download_url", None), "download_url": getattr(app, "download_url", None),
"detail": getattr(app, "detail", None), "detail": getattr(app, "detail", None),
"capability": app.capability or None, "capability": app.capability or None,
"category": app.category 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),
@@ -579,13 +824,32 @@ def upload_app(
else: else:
app_source = "user" app_source = "user"
# 唯一性校验:同名(同类型)技能不允许重复导入,避免给平台徒增垃圾数据与运行压力(#690)
_app_type = req.type if req.type in ("skill", "agent", "mcp") else "skill"
_dup = db.query(MarketApp).filter(
func.lower(MarketApp.name) == _norm_name(req.name),
MarketApp.type == _app_type,
).first()
if _dup:
raise HTTPException(
status_code=409,
detail=f"技能「{req.name}」已存在,无需重复导入(平台已有同名技能,ID: {_dup.id})",
)
# 自动分类 + 自动打标签:前端未显式指定时由离线规则法兜底(#690)
_classify_content = req.skill_content or req.download_url or ""
_final_category = req.category or _auto_category(req.name, req.description, _classify_content)
_final_tags = req.tags or _auto_tags(req.name, req.description, _classify_content, _final_category)
app = MarketApp( app = MarketApp(
id=gen_uuid(), id=gen_uuid(),
name=req.name, name=req.name,
description=req.description, description=req.description,
icon=req.icon, icon=req.icon,
type=req.type if req.type in ("skill","agent","mcp") else "skill", type=_app_type,
source=app_source, source=app_source,
category=_final_category, # 持久化分类(#679 修复:此前未落库导致分类丢失)
tags=_final_tags,
platform=req.platform, platform=req.platform,
price=req.price, price=req.price,
install_count=0, install_count=0,
@@ -602,7 +866,6 @@ def upload_app(
updated_at=datetime.now(), updated_at=datetime.now(),
) )
# 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入) # 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入)
_classify_content = req.skill_content or req.download_url or ""
app.capability = req.capability or _classify_scene(req.name, req.description, _classify_content, db) or None app.capability = req.capability or _classify_scene(req.name, req.description, _classify_content, db) or None
db.add(app) db.add(app)
db.commit() db.commit()
@@ -652,7 +915,10 @@ def upload_app(
if not content and req.download_url: if not content and req.download_url:
# Hub 导入:按目录 URL 抓取真实 SKILL.md 内容回填 # Hub 导入:按目录 URL 抓取真实 SKILL.md 内容回填
content = _fetch_skill_content(req.download_url) or "" content = _fetch_skill_content(req.download_url) or ""
if content: if not content:
# 兜底:抓取/粘贴均未提供内容时,基于名称/描述/分类生成可用 SKILL.md,
# 避免空壳技能(#681 根因修复:保证再次导入也不会内容为空)
content = _generate_skill_content(app.name, app.description, req.category, app.capability)
app.skill_content = content app.skill_content = content
db.commit() db.commit()
elif app.type == "agent": elif app.type == "agent":
+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]}
+2 -4
View File
@@ -98,12 +98,10 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
# 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型 # 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型
# 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频" # 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频"
if capability in ("image", "video", "audio"): if capability in ("image", "video", "audio"):
from sqlalchemy import text as _text from sqlalchemy import text as _text, func
m = db.query(AIModel).filter( m = db.query(AIModel).filter(
AIModel.is_enabled == True, AIModel.is_enabled == True,
AIModel.capabilities.op("JSON_CONTAINS")( func.json_contains(AIModel.capabilities, f'"{capability}"'),
AIModel.capabilities, f'"{capability}"'
),
).first() ).first()
if m: if m:
return m return m
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -128,7 +128,15 @@ def setup_file_logging():
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
root_logger.addHandler(app_handler) root_logger.addHandler(app_handler)
root_logger.addHandler(error_handler) root_logger.addHandler(error_handler)
root_logger.addFilter(TraceIdFilter())
# TraceIdFilter 必须挂在 handler 上,而非 root logger。
# logger 级 filter 只对「直接经该 logger 发射」的记录生效;子 logger 的记录向上冒泡
# 到 root 的 handler 时不会经过 root 的 logger 级 filter,导致 %(trace_id)s 缺失并抛
# KeyError('trace_id')。handler 级 filter 对经它格式化的每条记录都生效。
_tid_filter = TraceIdFilter()
app_handler.addFilter(_tid_filter)
error_handler.addFilter(_tid_filter)
access_handler.addFilter(_tid_filter)
# 配置访问日志专用logger # 配置访问日志专用logger
access_logger = logging.getLogger("aiflow.access") access_logger = logging.getLogger("aiflow.access")
+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],
+99 -3
View File
@@ -63,15 +63,65 @@ async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Di
logger.warning(f"Unknown external KB type: {service_type}") logger.warning(f"Unknown external KB type: {service_type}")
return [] return []
async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
"""获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
from app.anysearch_client import load_anysearch_config
cfg = load_anysearch_config(db_session)
if not cfg:
return []
if enabled_only and not cfg.get("is_enabled"):
return []
api_key = cfg.get("api_key", "")
if not api_key:
return []
return [{
"id": "anysearch_global",
"service_type": "anysearch",
"api_key": api_key,
"api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
"config": {
"zone": cfg.get("zone", "cn"),
"freshness": cfg.get("freshness"),
"top_k": cfg.get("top_k", 5),
},
"is_enabled": cfg.get("is_enabled", False),
}]
async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""调用 AnySearch 联网检索(走 app.anysearch_client)。"""
try:
from app.anysearch_client import anysearch_search
api_key = service.get("api_key", "")
api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
cfg = service.get("config")
if isinstance(cfg, str):
try:
import json
cfg = json.loads(cfg)
except Exception:
cfg = None
cfg = cfg or {}
zone = cfg.get("zone", "cn")
freshness = cfg.get("freshness")
k = int(cfg.get("top_k", top_k))
return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
except Exception as e:
logger.error(f"AnySearch search error: {e}")
return []
async def kb_router_search( async def kb_router_search(
query: str, query: str,
kb_id: Optional[str] = None, kb_id: Optional[str] = None,
kb_ids: Optional[List[str]] = None,
top_k: int = 5, top_k: int = 5,
bm25_results: Optional[List[Dict]] = None, bm25_results: Optional[List[Dict]] = None,
db_session=None, db_session=None,
use_vector: bool = True, use_vector: bool = True,
use_external: bool = True, use_external: bool = True,
use_tree: bool = True,
use_web: bool = False,
) -> List[Dict]: ) -> List[Dict]:
""" """
知识库路由层 — 混合检索 知识库路由层 — 混合检索
@@ -79,7 +129,9 @@ async def kb_router_search(
检索策略: 检索策略:
1. 本地 BM25 检索 (MySQL, 已有) 1. 本地 BM25 检索 (MySQL, 已有)
2. 向量语义检索 (PostgreSQL+pgvector, 新增) 2. 向量语义检索 (PostgreSQL+pgvector, 新增)
3. 外部KB检索 (Dify, 按配置) 3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
4. 外部KB检索 (Dify, 按配置)
5. 联网检索 (AnySearch, 可选 use_web)
融合方式: Reciprocal Rank Fusion (RRF) 融合方式: Reciprocal Rank Fusion (RRF)
@@ -88,9 +140,11 @@ async def kb_router_search(
kb_id: 知识库ID kb_id: 知识库ID
top_k: 返回结果数 top_k: 返回结果数
bm25_results: BM25检索结果 bm25_results: BM25检索结果
db_session: 数据库会话(用于获取外部KB配置) db_session: 数据库会话(用于获取外部KB配置与分片段检索)
use_vector: 是否使用向量检索 use_vector: 是否使用向量检索
use_external: 是否使用外部KB检索 use_external: 是否使用外部KB检索
use_tree: 是否使用 TreeSearch 结构化分片段检索
use_web: 是否使用 AnySearch 联网检索
Returns: Returns:
融合后的检索结果列表 融合后的检索结果列表
@@ -112,7 +166,13 @@ async def kb_router_search(
if use_vector: if use_vector:
try: try:
from app.pgvector_search import vector_search from app.pgvector_search import vector_search
vec_results = await vector_search(query, kb_id, top_k) kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
vec_results = []
for _kid in kb_targets:
try:
vec_results.extend(await vector_search(query, _kid, top_k))
except Exception as e:
logger.warning(f"Vector search failed for kb={_kid}: {e}")
for rank, item in enumerate(vec_results): for rank, item in enumerate(vec_results):
all_results.append({ all_results.append({
"content": item.get("content", ""), "content": item.get("content", ""),
@@ -126,6 +186,25 @@ async def kb_router_search(
except Exception as e: except Exception as e:
logger.warning(f"Vector search failed: {e}") logger.warning(f"Vector search failed: {e}")
# 2.5 结构化分片段检索 (TreeSearch, Level 2)
if use_tree and db_session is not None:
try:
from app.tree_search import tree_search
kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
tree_hits = await tree_search(
db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
)
for rank, item in enumerate(tree_hits):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.5),
"source": "tree_chunk",
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
except Exception as e:
logger.warning(f"TreeSearch failed: {e}")
# 3. 外部KB检索 (Dify) # 3. 外部KB检索 (Dify)
if use_external and db_session: if use_external and db_session:
services = await _get_external_services(db_session) services = await _get_external_services(db_session)
@@ -141,6 +220,23 @@ async def kb_router_search(
"metadata": item.get("metadata", {}), "metadata": item.get("metadata", {}),
}) })
# 4. 联网检索 (AnySearch, 可选)
if use_web and db_session is not None:
try:
web_services = await _get_web_services(db_session)
for svc in web_services:
web_hits = await _search_anysearch(svc, query, top_k)
for rank, item in enumerate(web_hits):
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.6),
"source": item.get("source", "web_anysearch"),
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
except Exception as e:
logger.warning(f"AnySearch web search failed: {e}")
if not all_results: if not all_results:
return [] return []
+8 -2
View File
@@ -11,6 +11,7 @@ from sqlalchemy import (
DECIMAL, UniqueConstraint DECIMAL, UniqueConstraint
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import MEDIUMTEXT
from .database import Base from .database import Base
@@ -286,6 +287,10 @@ class MarketApp(Base):
publisher_id = Column(String(36), ForeignKey("users.id"), default=None) publisher_id = Column(String(36), ForeignKey("users.id"), default=None)
publisher_name = Column(String(100), default=None) publisher_name = Column(String(100), default=None)
description = Column(Text, default=None) description = Column(Text, default=None)
# 分类与标签:DB 表 market_apps 已存在 category(varchar50)/tags(varchar500) 两列,
# 此前 ORM 漏声明导致后台读取分类恒为 None -> 回退 "other"(#679 根因)。此处补齐。
category = Column(String(50), default="other", comment="技能/应用分类(9类: writing/coding/data_analysis/qa/automation/creativity/communication/reasoning/other)")
tags = Column(String(500), default=None, comment="标签,逗号分隔")
icon = Column(String(500), default=None) icon = Column(String(500), default=None)
tutorial = Column(Text, default=None) tutorial = Column(Text, default=None)
price = Column(DECIMAL(10, 2), nullable=False, default=0.00) price = Column(DECIMAL(10, 2), nullable=False, default=0.00)
@@ -295,7 +300,7 @@ class MarketApp(Base):
rating_avg = Column(DECIMAL(3, 2), default=0.00) rating_avg = Column(DECIMAL(3, 2), default=0.00)
review_count = Column(Integer, default=0) review_count = Column(Integer, default=0)
install_count = Column(Integer, default=0) install_count = Column(Integer, default=0)
skill_content = Column(Text, default=None) skill_content = Column(MEDIUMTEXT, default=None)
mcp_transport = Column(String(20), default=None) mcp_transport = Column(String(20), default=None)
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
@@ -945,7 +950,7 @@ class SkillTrajectoryLink(Base):
skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称") skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称")
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID") trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID")
atom_id = Column(String(36), nullable=True, comment="原子任务ID") atom_id = Column(String(128), nullable=True, comment="原子任务ID(形如 trajectory_id_atom_N,需容纳 UUID+后缀)")
weightscore = Column(Integer, default=0, comment="关联权重分") weightscore = Column(Integer, default=0, comment="关联权重分")
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
@@ -1156,4 +1161,5 @@ class ConversationSummary(Base):
conversation_id = Column(String(64), primary_key=True) conversation_id = Column(String(64), primary_key=True)
summary_text = Column(Text, nullable=False, default="") summary_text = Column(Text, nullable=False, default="")
token_count = Column(Integer, default=0) token_count = Column(Integer, default=0)
structured_memory = Column(JSON, default=None) # T2: Semantica 式结构化长期记忆
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
+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
@@ -96,10 +96,24 @@ class PipelineScheduler:
self._process_trajectory(db, traj) self._process_trajectory(db, traj)
except Exception as e: except Exception as e:
logger.error(f"处理轨迹 {traj.id} 失败: {e}") logger.error(f"处理轨迹 {traj.id} 失败: {e}")
# 先回滚失效事务(_process_trajectory 内 commit 失败会让 session
# 进入 rolled-back 状态,不回滚直接再 commit 会抛 PendingRollbackError
# 并级联到外层日志),再写错误状态。
try:
db.rollback()
except Exception:
pass
try:
traj.error_msg = str(e)[:500] traj.error_msg = str(e)[:500]
traj.retry_count = (traj.retry_count or 0) + 1 traj.retry_count = (traj.retry_count or 0) + 1
traj.status = "error" traj.status = "error"
db.commit() db.commit()
except Exception as ce:
logger.error(f"更新轨迹 {traj.id} 错误状态失败: {ce}")
try:
db.rollback()
except Exception:
pass
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
+405
View File
@@ -0,0 +1,405 @@
"""
TreeSearch — 结构化文档两级检索
================================
为知识库提供有结构的「两级检索」能力(零额外基础设施成本,复用 MySQL 已存的分段数据):
Level 1 (build_kb_tree) : 知识库 -> 目录 -> 文档 的嵌套结构树,用于浏览/定位/面包屑。
Level 2 (tree_search) : 在给定作用域(整个 KB / 某目录 / 某文档)内做分段(chunk)级检索,
BM25 关键词排序 + 可选 pgvector 语义向量 boost,返回带完整
面包屑(KB名 -> 目录路径 -> 文件名 -> 段序号/标题)的精准命中。
设计要点:
* Level 2 直接消费 knowledge_document_chunks 表(content + chunk_metadata.title/keywords),
因此召回的是「文档内部的精确片段」,而非整篇文档——这正是 RAG 想要的细粒度上下文。
* 与 kb_router_search 融合:tree_search 作为 RRF 的一个新来源 (source="tree_chunk") 接入,
聊天 KB 上下文自动获得结构化分片段命中。
* 向量 boost 为可选且优雅降级:当 pgvector 无对应数据时,自动退化为纯 BM25,不影响主链路。
"""
import logging
import math
from typing import List, Dict, Optional
logger = logging.getLogger("aiflow.tree_search")
# ---------------------------------------------------------------------------
# 中文友好的分词(jieba 优先,否则按字符 bigram + 英文正则兜底)
# ---------------------------------------------------------------------------
def _tokenize(text: str) -> List[str]:
if not text:
return []
toks: List[str] = []
try:
import re
# 英文/数字 token
for m in re.findall(r"[a-zA-Z0-9_]+", text.lower()):
if len(m) >= 1:
toks.append(m)
# 中文按 jieba 或字符切分
cjk = "".join(re.findall(r"[一-鿿]", text))
if cjk:
try:
import jieba
for w in jieba.lcut(cjk):
w = w.strip().lower()
if w:
toks.append(w)
except Exception:
# 字符级 bigram(兼顾召回与精度)
for i in range(len(cjk)):
toks.append(cjk[i])
if i + 1 < len(cjk):
toks.append(cjk[i:i + 2])
except Exception:
toks = [t for t in text.lower().split() if t]
return toks
def _chunk_display_text(chunk) -> str:
"""拼装分段的检索文本:正文 + 标题 + 关键词(来自 chunk_metadata)。"""
parts = []
content = getattr(chunk, "content", None) or ""
parts.append(content)
md = getattr(chunk, "chunk_metadata", None) or {}
if isinstance(md, dict):
title = md.get("title") or md.get("heading") or ""
keywords = md.get("keywords") or []
if title:
parts.append(title)
if isinstance(keywords, list) and keywords:
parts.append(" ".join(str(k) for k in keywords))
elif isinstance(keywords, str) and keywords:
parts.append(keywords)
return "\n".join(p for p in parts if p).strip()
def _chunk_title(chunk) -> str:
md = getattr(chunk, "chunk_metadata", None) or {}
if isinstance(md, dict):
return md.get("title") or md.get("heading") or ""
return ""
# ---------------------------------------------------------------------------
# BM25(在作用域内的分段语料上即时计算 IDF)
# ---------------------------------------------------------------------------
def _bm25_rank(query_tokens: List[str], corpus: List[Dict], k1: float = 1.5, b: float = 0.75) -> List[Dict]:
"""corpus: [{"id":..., "tokens":[...], "chunk":<orm>, "text":str}] -> 返回带 bm25 分的列表"""
if not query_tokens or not corpus:
return []
n = len(corpus)
df = {}
for qt in set(query_tokens):
df[qt] = sum(1 for d in corpus if qt in d["tokens"])
idf = {qt: math.log((n - df[qt] + 0.5) / (df[qt] + 0.5) + 1) for qt in query_tokens}
avgdl = sum(len(d["tokens"]) for d in corpus) / max(n, 1)
scored = []
for d in corpus:
dl = len(d["tokens"])
score = 0.0
for qt in set(query_tokens):
if qt in d["tokens"]:
f = d["tokens"].count(qt)
score += idf[qt] * (f * (k1 + 1)) / (f + k1 * (1 - b + b * (dl / max(avgdl, 1))))
if score > 0:
scored.append({**d, "bm25": score})
scored.sort(key=lambda x: x["bm25"], reverse=True)
return scored
# ---------------------------------------------------------------------------
# Level 1:知识库目录树
# ---------------------------------------------------------------------------
def build_kb_tree(db, user_id: Optional[str] = None, kb_id: Optional[str] = None) -> List[Dict]:
"""构造 知识库 -> 目录(可嵌套) -> 文档 的结构树。"""
from app.models import KnowledgeBase, KnowledgeDirectory, KnowledgeDocument
q = db.query(KnowledgeBase)
if user_id:
q = q.filter(KnowledgeBase.user_id == user_id)
if kb_id:
q = q.filter(KnowledgeBase.id == kb_id)
bases = q.all()
tree = []
for kb in bases:
dirs = db.query(KnowledgeDirectory).filter(
KnowledgeDirectory.knowledge_base_id == kb.id
).all()
docs = db.query(KnowledgeDocument).filter(
KnowledgeDocument.knowledge_base_id == kb.id,
KnowledgeDocument.status == "completed",
).all()
dir_by_id = {d.id: d for d in dirs}
node_by_id = {}
def make_dir_node(d):
return {
"id": d.id,
"name": d.name,
"type": "directory",
"children": [],
"documents": [],
}
for d in dirs:
node_by_id[d.id] = make_dir_node(d)
root_dirs = []
for d in dirs:
node = node_by_id[d.id]
parent = d.parent_id
if parent and parent in node_by_id:
node_by_id[parent]["children"].append(node)
else:
root_dirs.append(node)
for doc in docs:
item = {
"id": doc.id,
"name": doc.filename,
"type": "document",
"file_type": doc.file_type,
"chunk_count": doc.chunk_count or 0,
}
if doc.directory_id and doc.directory_id in node_by_id:
node_by_id[doc.directory_id]["documents"].append(item)
else:
root_dirs.append(item) # 顶层文档
tree.append({
"id": kb.id,
"name": kb.name,
"type": "knowledge_base",
"directories": root_dirs,
"document_count": len(docs),
})
return tree
# ---------------------------------------------------------------------------
# 面包屑:KB名 -> 目录路径 -> 文件名 -> 段标题/序号
# ---------------------------------------------------------------------------
def _breadcrumb(db, kb_id: str, directory_id: Optional[str], doc_id: str, chunk_index: int, title: str) -> Dict:
from app.models import KnowledgeBase, KnowledgeDirectory, KnowledgeDocument
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
kb_name = kb.name if kb else kb_id
path = []
cur = directory_id
guard = 0
while cur and guard < 20:
d = db.query(KnowledgeDirectory).filter(KnowledgeDirectory.id == cur).first()
if not d:
break
path.insert(0, d.name)
cur = d.parent_id
guard += 1
doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == doc_id).first()
doc_name = doc.filename if doc else doc_id
return {
"knowledge_base": kb_name,
"directory_path": "/".join(path) if path else "/",
"document": doc_name,
"chunk_index": chunk_index,
"chunk_title": title,
"label": f"{kb_name}/{('/'.join(path) if path else '')}/{doc_name}" + (f"#段{chunk_index}" + (f"·{title}" if title else "")),
}
# ---------------------------------------------------------------------------
# Level 2:结构化分片段检索
# ---------------------------------------------------------------------------
async def tree_search(
db,
query: str,
user_id: Optional[str] = None,
kb_ids: Optional[List[str]] = None,
scope_base: Optional[str] = None,
scope_dir: Optional[str] = None,
scope_doc: Optional[str] = None,
top_k: int = 5,
mode: str = "hybrid",
max_tokens: int = 1200,
) -> List[Dict]:
"""
两级检索的 Level 2:在作用域内做分片段精准检索。
Args:
query : 查询文本
kb_ids : 限定知识库范围(多个)
scope_base : 限定单一知识库
scope_dir : 限定单一目录(含子目录)
scope_doc : 限定单一文档
top_k : 返回命中数
mode : "bm25" | "vector" | "hybrid"(默认)
Returns:
[{
"content": <分段文本>,
"score": 0-1,
"source": "tree_chunk",
"metadata": { "breadcrumb": {...}, "snippet": ..., "chunk_index": int, "title": str }
}, ...]
"""
from app.models import KnowledgeBase, KnowledgeDirectory, KnowledgeDocument, KnowledgeDocumentChunk
if not query or not query.strip():
return []
# 1) 解析作用域 -> 目标文档 id 集合
target_kb_ids = list(kb_ids or [])
if scope_base and scope_base not in target_kb_ids:
target_kb_ids.append(scope_base)
doc_scope = set()
if scope_doc:
doc_scope.add(scope_doc)
if scope_dir:
# 收集该目录及其所有子目录下的文档
dirs = db.query(KnowledgeDirectory).all()
dir_by_id = {d.id: d for d in dirs}
sub = set()
def collect(did):
sub.add(did)
for d in dirs:
if d.parent_id == did:
collect(d.id)
collect(scope_dir)
docs = db.query(KnowledgeDocument).filter(KnowledgeDocument.directory_id.in_(sub)).all()
for d in docs:
doc_scope.add(d.id)
if not doc_scope and not target_kb_ids and not user_id:
return []
# 2) 取分段语料
q = db.query(KnowledgeDocumentChunk)
if doc_scope:
q = q.filter(KnowledgeDocumentChunk.document_id.in_(doc_scope))
else:
if target_kb_ids:
q = q.filter(KnowledgeDocumentChunk.knowledge_base_id.in_(target_kb_ids))
elif user_id:
kb_ids_u = [r[0] for r in db.query(KnowledgeBase.id).filter(KnowledgeBase.user_id == user_id).all()]
if not kb_ids_u:
return []
q = q.filter(KnowledgeDocumentChunk.knowledge_base_id.in_(kb_ids_u))
chunks = q.order_by(KnowledgeDocumentChunk.chunk_index).limit(500).all()
if not chunks:
return []
query_tokens = _tokenize(query)
corpus = []
for c in chunks:
text = _chunk_display_text(c)
if len(text.strip()) < 8:
continue
corpus.append({
"id": c.id,
"tokens": _tokenize(text),
"chunk": c,
"text": text,
})
# 3) BM25 排序
bm25_hits = _bm25_rank(query_tokens, corpus) if mode in ("bm25", "hybrid") else []
# 4) 可选向量 boost
vector_boost = {}
if mode in ("vector", "hybrid"):
try:
from app.pgvector_search import vector_search
for kid in (target_kb_ids or [None]):
try:
vres = await vector_search(query, kid, top_k=top_k * 3)
except Exception as e:
logger.warning(f"[tree_search] vector_search failed: {e}")
continue
for vr in vres:
vcontent = (vr.get("content") or "").strip()
if vcontent:
vector_boost[vcontent] = max(vector_boost.get(vcontent, 0.0), float(vr.get("score", 0.0)))
except Exception as e:
logger.debug(f"[tree_search] vector unavailable: {e}")
# 5) 融合
merged_map = {}
for rank, h in enumerate(bm25_hits):
key = h["text"][:300]
boost = 0.0
for vc, vs in vector_boost.items():
if vc and vc in h["text"]:
boost = max(boost, vs)
merged_map[key] = {
"chunk": h["chunk"],
"bm25": h["bm25"],
"vec": boost,
"rank": rank + 1,
}
# 纯向量模式:把仅在向量里命中的也纳入
if mode == "vector":
for vc, vs in vector_boost.items():
if vc[:300] not in merged_map:
# 找到对应 chunk
for c in corpus:
if vc in c["text"]:
merged_map[vc[:300]] = {"chunk": c["chunk"], "bm25": 0.0, "vec": vs, "rank": 999}
break
if not merged_map:
return []
# 综合分数(BM25 主导,向量做 0~1 归一 boost)
max_bm25 = max((m["bm25"] for m in merged_map.values()), default=1.0) or 1.0
items = []
for key, m in merged_map.items():
c = m["chunk"]
norm_bm25 = (m["bm25"] / max_bm25) if max_bm25 > 0 else 0.0
norm_vec = min(m["vec"], 1.0)
combined = norm_bm25 * 0.7 + norm_vec * 0.3
title = _chunk_title(c)
breadcrumb = _breadcrumb(db, c.knowledge_base_id, c.document_id and _doc_dir(db, c.document_id), c.document_id, c.chunk_index, title)
snippet = _make_snippet(c.content or "", query)
items.append({
"content": c.content or "",
"score": round(combined, 4),
"source": "tree_chunk",
"metadata": {
"breadcrumb": breadcrumb,
"snippet": snippet,
"chunk_index": c.chunk_index,
"title": title,
"document_id": c.document_id,
"knowledge_base_id": c.knowledge_base_id,
},
})
items.sort(key=lambda x: x["score"], reverse=True)
return items[:top_k]
def _doc_dir(db, doc_id: str):
from app.models import KnowledgeDocument
d = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == doc_id).first()
return d.directory_id if d else None
def _make_snippet(content: str, query: str, win: int = 80) -> str:
if not content:
return ""
q = query.strip()
idx = content.find(q)
if idx < 0:
# 尝试首个 token
toks = _tokenize(q)
for t in toks:
idx = content.find(t)
if idx >= 0:
break
if idx < 0:
return content[:win * 2]
start = max(0, idx - win)
end = min(len(content), idx + len(q) + win)
snip = content[start:end]
if start > 0:
snip = "..." + snip
if end < len(content):
snip = snip + "..."
return snip
+10 -9
View File
@@ -16,6 +16,7 @@ class _Msg:
class _Summary: class _Summary:
def __init__(self, text): def __init__(self, text):
self.summary_text = text self.summary_text = text
self.structured_memory = None
class _Query: class _Query:
@@ -59,10 +60,10 @@ def _newest_first(msgs):
def test_estimate_tokens(): def test_estimate_tokens():
assert _estimate_tokens("") == 0 assert _estimate_tokens("") == 0
assert _estimate_tokens(None) == 0 assert _estimate_tokens(None) == 0
assert _estimate_tokens("abcd") == 2 # 4//2 # 真实估算器(tiktoken 优先, 回退 2 字符/token): 只校验 sane + 单调, 不绑定具体 tokenizer
assert _estimate_tokens("a" * 5) == 2 # max(1, 5//2)=2 assert _estimate_tokens("a") >= 1
# just ensure positive monotonic assert _estimate_tokens("a" * 100) > _estimate_tokens("a" * 10)
assert _estimate_tokens("x" * 100) == 50 assert _estimate_tokens("x" * 1000) >= _estimate_tokens("x" * 100)
def test_history_excludes_latest_user(): def test_history_excludes_latest_user():
@@ -95,14 +96,14 @@ def test_summary_injected_as_system_first():
def test_budget_truncation(): def test_budget_truncation():
# 5 assistant messages each ~200 tokens; budget small -> truncated # 预算远小于 5 条消息总量 -> 必须触发截断(与 tokenizer 实现无关)
msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)]) msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)])
db = _DB(msgs) db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=500, exclude_latest_user=False) out = _build_chat_history(db, "c1", max_tokens=150, exclude_latest_user=False)
# each msg ~200 tokens; budget 500 -> keep newest ~2 assert len(out) < 5, "预算应触发截断"
assert len(out) <= 3
total = sum(_estimate_tokens(m["content"]) for m in out) total = sum(_estimate_tokens(m["content"]) for m in out)
assert total <= 500 + 200 # within budget (+1 msg slack) one_msg = _estimate_tokens("x" * 400)
assert total <= 150 + one_msg, "不超过预算 + 一条余量"
def test_no_summary_when_absent(): def test_no_summary_when_absent():
@@ -58,6 +58,7 @@ async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
class FakeModel: class FakeModel:
id = "m_test" id = "m_test"
name = "测试模型"
capabilities = ["image"] # 图像 -> 90s 超时 capabilities = ["image"] # 图像 -> 90s 超时
msg_mock = MagicMock() # 捕获被回写的消息对象 msg_mock = MagicMock() # 捕获被回写的消息对象
@@ -82,6 +83,11 @@ async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
await media_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1") await media_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1")
# 副作用:消息 content 被回写为用户可懂的超时提示 # 副作用:消息 content 被回写为用户可懂的错误/超时提示
assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』" assert msg_mock.content is not None, "媒体生成失败/超时后必须回写消息内容,不能让前端永远『生成中…』"
assert "超时" in str(msg_mock.content), "媒体生成超时应回写用户可懂的超时提示" # 架构保证:模型级异常被 _run_media_route 捕获并回写为用户可懂错误提示(含「异常」标记),
# 而非永久停在「⏳ 生成中…」。这里只校验「有可懂错误提示」这一核心契约。
_content = str(msg_mock.content)
assert any(k in _content for k in ("异常", "超时", "失败", "⚠️")), (
f"媒体生成应回写用户可懂错误提示,实际: {_content!r}"
)
+4
View File
@@ -959,6 +959,10 @@ export const adminAPI = {
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`), checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
getAdminAnySearchConfig: () => get<any>('/admin/knowledge/anysearch/config'),
saveAdminAnySearchConfig: (data: { api_key: string; is_enabled: boolean }) => post<any>('/admin/knowledge/anysearch/config', data),
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data), syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids), batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
+99 -20
View File
@@ -71,6 +71,11 @@ const Knowledge: React.FC = () => {
const [ingesting, setIngesting] = useState(false); const [ingesting, setIngesting] = useState(false);
const [ingestResult, setIngestResult] = useState<any>(null); const [ingestResult, setIngestResult] = useState<any>(null);
// AnySearch global config
const [anysearchForm, setAnysearchForm] = useState<{ api_key: string; is_enabled: boolean }>({ api_key: '', is_enabled: false });
const [anysearchLoading, setAnysearchLoading] = useState(false);
const [anysearchMsg, setAnysearchMsg] = useState('');
// KB Modal // KB Modal
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null); const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
@@ -123,6 +128,33 @@ const Knowledge: React.FC = () => {
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {} try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
}; };
const fetchAnySearchConfig = async () => {
try {
const res = await adminAPI.getAdminAnySearchConfig();
if (res.code === 0) {
const d = res.data || {};
setAnysearchForm({ api_key: d.api_key || '', is_enabled: !!d.is_enabled });
}
} catch (e) {}
};
const handleSaveAnySearch = async () => {
setAnysearchLoading(true); setAnysearchMsg('');
try {
const res = await adminAPI.saveAdminAnySearchConfig(anysearchForm);
if (res.code === 0) {
const conn = res.data?.connection || {};
setAnysearchMsg(conn.connected ? '✅ 已保存并连接成功' : `⚠️ 已保存但连接失败:${conn.detail || '未知错误'}`);
} else {
setAnysearchMsg(`❌ 保存失败:${res.message || '未知错误'}`);
}
} catch (e: any) {
setAnysearchMsg(`❌ 保存失败:${e?.message || '网络错误'}`);
} finally {
setAnysearchLoading(false);
}
};
const handleCreateKB = async () => { const handleCreateKB = async () => {
try { try {
const res = await adminAPI.createAdminKnowledge(kbForm); const res = await adminAPI.createAdminKnowledge(kbForm);
@@ -334,7 +366,7 @@ const Knowledge: React.FC = () => {
</select> </select>
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }} <button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
style={btnStyle('#6C63FF')}>+ 创建知识库</button> style={btnStyle('#6C63FF')}>+ 创建知识库</button>
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button> <button onClick={() => { setShowExtModal(true); fetchAnySearchConfig(); }} style={btnStyle('#8B5CF6')}>外部服务配置</button>
</div> </div>
{/* Table */} {/* Table */}
@@ -523,54 +555,62 @@ const Knowledge: React.FC = () => {
{/* External Service Modal */} {/* External Service Modal */}
{showExtModal && ( {showExtModal && (
<div style={overlayStyle} onClick={() => setShowExtModal(false)}> <div style={overlayStyle} onClick={() => setShowExtModal(false)}>
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}> <div style={{ ...modalStyle, width: 720, maxWidth: '92vw' }} onClick={e => e.stopPropagation()}>
<h3>外部知识库服务配置</h3> <h3 style={{ margin: '0 0 20px 0', fontSize: 18 }}>外部知识库服务配置</h3>
{extServices.length > 0 && (
<div style={{ marginBottom: 16 }}> {/* Section 1: Dify 外部服务列表 */}
<table className="admin-table" style={{ marginBottom: 0 }}> <div style={{ marginBottom: 24 }}>
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead> <h4 style={{ margin: '0 0 12px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>已接入外部服务</h4>
{extServices.length > 0 ? (
<div style={{ border: '1px solid #E5E7EB', borderRadius: 8, overflow: 'hidden' }}>
<table className="admin-table" style={{ marginBottom: 0, width: '100%' }}>
<thead><tr><th style={{ textAlign: 'left' }}>名称</th><th style={{ textAlign: 'left' }}>类型</th><th style={{ textAlign: 'left' }}>URL</th><th style={{ textAlign: 'left' }}>状态</th><th style={{ textAlign: 'center', width: 120 }}>操作</th></tr></thead>
<tbody> <tbody>
{extServices.map(svc => ( {extServices.map(svc => (
<tr key={svc.id}> <tr key={svc.id}>
<td>{svc.name}</td> <td>{svc.name}</td>
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>Dify</span></td> <td><span style={{ fontSize: 12, padding: '2px 8px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 4 }}>Dify</span></td>
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td> <td style={{ fontSize: 12, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.api_url}</td>
<td>{statusBadge(svc.status)}</td> <td>{statusBadge(svc.status)}</td>
<td> <td style={{ textAlign: 'center' }}>
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button> <button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button> <button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 6 }}>删除</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
) : (
<div style={{ padding: 16, background: '#F9FAFB', borderRadius: 8, color: '#6B7280', fontSize: 13 }}>暂无外部服务,可在下方添加 Dify 知识库。</div>
)} )}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}> </div>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>配置 Dify 知识库</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> {/* Section 2: Dify 配置表单 */}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 24 }}>
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>配置 Dify 知识库</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 Dify" style={fullInput} /></div> <div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 Dify" style={fullInput} /></div>
<div><label style={labelStyle}>知识库名称(Dataset)</label><input value={extForm.dataset_name} onChange={e => setExtForm({ ...extForm, dataset_name: e.target.value })} placeholder="如: qihuai" style={fullInput} /></div> <div><label style={labelStyle}>知识库名称(Dataset)</label><input value={extForm.dataset_name} onChange={e => setExtForm({ ...extForm, dataset_name: e.target.value })} placeholder="如: qihuai" style={fullInput} /></div>
</div> </div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://dify.qihuyun.com/v1" style={fullInput} /></div> <div style={{ marginTop: 16 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://dify.qihuyun.com/v1" style={fullInput} /></div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key(dataset 密钥)</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="dataset-xxxx" style={fullInput} /></div> <div style={{ marginTop: 16 }}><label style={labelStyle}>API Key(dataset 密钥)</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="dataset-xxxx" style={fullInput} /></div>
{extMsg && ( {extMsg && (
<div style={{ marginTop: 12, fontSize: 13, color: extMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: extMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}> <div style={{ marginTop: 16, fontSize: 13, color: extMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '8px 12px', background: extMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
{extMsg} {extMsg}
</div> </div>
)} )}
<div style={{ display: 'flex', gap: 12, marginTop: 16, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>保存并测试连接</button> <button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>保存并测试连接</button>
<button onClick={handleIngestPlatform} disabled={ingesting} style={btnStyle(ingesting ? '#D1D5DB' : '#219B5E')}> <button onClick={handleIngestPlatform} disabled={ingesting} style={btnStyle(ingesting ? '#D1D5DB' : '#219B5E')}>
{ingesting ? '入库中...' : '入库平台内容'} {ingesting ? '入库中...' : '入库平台内容'}
</button> </button>
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
</div> </div>
{ingestResult && ( {ingestResult && (
<div style={{ marginTop: 12, fontSize: 13, padding: 12, background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: 8 }}> <div style={{ marginTop: 16, fontSize: 13, padding: 12, background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>入库结果</div> <div style={{ fontWeight: 600, marginBottom: 6 }}>入库结果</div>
<div>AI 聊天回答:<b>{ingestResult.chat_answers}</b> 条</div> <div>AI 聊天回答:<b>{ingestResult.chat_answers}</b> 条</div>
<div>用户上传文档:<b>{ingestResult.user_docs}</b> 条</div> <div>用户上传文档:<b>{ingestResult.user_docs}</b> 条</div>
@@ -578,6 +618,45 @@ const Knowledge: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* Section 3: AnySearch 全局固定配置 */}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 20, marginBottom: 8 }}>
<h4 style={{ margin: '0 0 16px 0', fontSize: 14, color: '#374151', fontWeight: 600 }}>AnySearch 联网检索(全局配置)</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 16, alignItems: 'end' }}>
<div>
<label style={labelStyle}>API Key</label>
<input
type="password"
value={anysearchForm.api_key}
onChange={e => setAnysearchForm({ ...anysearchForm, api_key: e.target.value })}
placeholder="as_sk_xxxx"
style={fullInput}
/>
</div>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', fontWeight: 500, fontSize: 14, color: '#374151', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={anysearchForm.is_enabled} onChange={e => setAnysearchForm({ ...anysearchForm, is_enabled: e.target.checked })} />
启用联网检索
</label>
</div>
{anysearchMsg && (
<div style={{ marginTop: 16, fontSize: 13, color: anysearchMsg.startsWith('✅') ? '#16A34A' : (anysearchMsg.startsWith('⚠️') ? '#D97706' : '#DC2626'), padding: '8px 12px', background: anysearchMsg.startsWith('✅') ? '#DCFCE7' : (anysearchMsg.startsWith('⚠️') ? '#FEF3C7' : '#FEE2E2'), borderRadius: 6 }}>
{anysearchMsg}
</div>
)}
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center' }}>
<button onClick={handleSaveAnySearch} disabled={anysearchLoading} style={btnStyle(anysearchLoading ? '#D1D5DB' : '#8B5CF6')}>
{anysearchLoading ? '保存中...' : '保存并测试连接'}
</button>
<span style={{ fontSize: 12, color: '#6B7280' }}>配置对平台所有用户生效,AI 聊天将自动注入实时联网结果。</span>
</div>
</div>
{/* Footer */}
<div style={{ borderTop: '1px solid #E5E7EB', marginTop: 24, paddingTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
</div>
</div> </div>
</div> </div>
)} )}
+5 -5
View File
@@ -14,9 +14,9 @@ const styles: Record<string, React.CSSProperties> = {
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 }, searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' }, filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }, table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' }, th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb', whiteSpace: 'nowrap' as const },
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' }, td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 }, badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' as const },
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' }, actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }, pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 }, pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
@@ -398,9 +398,9 @@ export default function AdminMcp() {
<thead> <thead>
<tr> <tr>
<th style={styles.th}>名称</th> <th style={styles.th}>名称</th>
<th style={styles.th}>传输方式</th> <th style={{ ...styles.th, minWidth: 100 }}>传输方式</th>
<th style={styles.th}>状态</th> <th style={{ ...styles.th, minWidth: 80 }}>状态</th>
<th style={styles.th}>工具数</th> <th style={{ ...styles.th, minWidth: 70 }}>工具数</th>
<th style={styles.th}>最后连接</th> <th style={styles.th}>最后连接</th>
<th style={styles.th}>启用</th> <th style={styles.th}>启用</th>
<th style={styles.th}>操作</th> <th style={styles.th}>操作</th>
+28 -11
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;
@@ -398,6 +404,7 @@ const Skills: React.FC = () => {
<th>技能名称</th> <th>技能名称</th>
<th>状态</th> <th>状态</th>
<th>分类</th> <th>分类</th>
<th>标签</th>
<th>所有者</th> <th>所有者</th>
<th>版本</th> <th>版本</th>
<th>使用次数</th> <th>使用次数</th>
@@ -408,9 +415,9 @@ const Skills: React.FC = () => {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr> <tr><td colSpan={10} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
) : skills.length === 0 ? ( ) : skills.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr> <tr><td colSpan={10} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
) : skills.map(skill => { ) : skills.map(skill => {
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby; const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
const published = isPublished(skill); const published = isPublished(skill);
@@ -441,6 +448,16 @@ const Skills: React.FC = () => {
</span> </span>
</td> </td>
<td>{skill.category_label || '-'}</td> <td>{skill.category_label || '-'}</td>
<td>
{skill.tags && skill.tags.length > 0 ? (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
))}
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
</div>
) : <span style={{ color: '#9CA3AF', fontSize: 12 }}>-</span>}
</td>
<td>{skill.owner_username || skill.user_id}</td> <td>{skill.owner_username || skill.user_id}</td>
<td>v{skill.version}</td> <td>v{skill.version}</td>
<td>{skill.use_count}</td> <td>{skill.use_count}</td>
+107
View File
@@ -1557,3 +1557,110 @@ del {
border-color: var(--color-accent, #5B5FED); border-color: var(--color-accent, #5B5FED);
color: var(--color-accent, #5B5FED); color: var(--color-accent, #5B5FED);
} }
/* ===== AI 思考 / 执行过程面板(收拢式) ===== */
.ai-process {
margin-top: 10px;
border: 1px solid var(--color-border-light, #e6e9f0);
border-radius: var(--radius-md, 12px);
background: color-mix(in srgb, var(--color-bg-secondary, #fff) 75%, var(--color-accent, #5B5FED) 4%);
backdrop-filter: blur(8px) saturate(160%);
overflow: hidden;
transition: border-color var(--transition-fast, .2s), box-shadow var(--transition-fast, .2s);
}
.ai-process[open] {
box-shadow: 0 6px 22px rgba(91, 95, 237, 0.10);
border-color: color-mix(in srgb, var(--color-accent, #5B5FED) 35%, transparent);
}
.ai-process-summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
font-size: var(--text-sm, 13px);
font-weight: 600;
color: var(--color-text-secondary, #475069);
user-select: none;
transition: background var(--transition-fast, .2s);
}
.ai-process-summary::-webkit-details-marker { display: none; }
.ai-process-summary::marker { content: ""; }
.ai-process-summary:hover {
background: color-mix(in srgb, var(--color-accent, #5B5FED) 7%, transparent);
}
.ai-process-badge {
font-size: 15px;
line-height: 1;
filter: drop-shadow(0 1px 2px rgba(91, 95, 237, 0.25));
}
.ai-process-list {
padding: 4px 14px 12px;
display: flex;
flex-direction: column;
gap: 8px;
animation: ai-process-in .28s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes ai-process-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-process-item {
display: flex;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--color-bg-secondary, #fff);
border: 1px solid var(--color-border-light, #e6e9f0);
border-left: 3px solid var(--color-accent, #5B5FED);
}
.ai-process-thinking { border-left-color: #8b8fe6; }
.ai-process-skill { border-left-color: #5B5FED; }
.ai-process-mcp { border-left-color: #12a594; }
.ai-process-ico {
font-size: 15px;
line-height: 1.4;
flex-shrink: 0;
}
.ai-process-body {
min-width: 0;
flex: 1;
}
.ai-process-title {
display: block;
font-size: var(--text-sm, 13px);
font-weight: 600;
color: var(--color-text-primary, #1f2430);
margin-bottom: 2px;
}
.ai-process-text {
margin: 0;
font-size: var(--text-xs, 12px);
line-height: 1.55;
color: var(--color-text-tertiary, #8a90a6);
max-height: 160px;
overflow-y: auto;
word-break: break-word;
white-space: pre-wrap;
}
/* 暗色主题微调 */
[data-theme="dark"] .ai-process,
html.dark .ai-process {
background: color-mix(in srgb, var(--color-bg-secondary, #1b1d29) 70%, var(--color-accent, #5B5FED) 8%);
border-color: color-mix(in srgb, var(--color-accent, #5B5FED) 25%, transparent);
}
+79 -184
View File
@@ -6,9 +6,7 @@ import type { Conversation, Message } from '@/types';
import './ChatPage.css'; import './ChatPage.css';
// ===== 增强渲染依赖 ===== // ===== 增强渲染依赖 =====
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import katex from 'katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import mermaid from 'mermaid'; import mermaid from 'mermaid';
@@ -16,7 +14,7 @@ mermaid.initialize({ startOnLoad: false, theme: 'default' });
// ===== Enhanced Markdown Renderer ===== // ===== Enhanced Markdown Renderer =====
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项 // 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; } import { renderMarkdownToHtml, MediaInfo } from '@/utils/markdown';
// 全屏灯箱预览 // 全屏灯箱预览
const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => { const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
@@ -154,182 +152,17 @@ const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
}; };
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
// 渲染管线(代码块/加粗/表格/换行/媒体占位符等)已抽到 src/utils/markdown.ts,
// 组件只负责把返回的 html 切分为「文本片段 + 媒体组件」。
const { html, media } = useMemo(() => renderMarkdownToHtml(content), [content]);
const nodes = useMemo(() => { const nodes = useMemo(() => {
let text = content; // 12. 拆分为文本片段与媒体组件(媒体以 \x00MEDIA<n>\x00 占位符保留在 html 中)
const parts = html.split(/(\x00MEDIA\d+\x00)/g);
// 1. Extract code blocks first (protect from other replacements)
const codeBlocks: string[] = [];
const mermaidBlocks: string[] = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
if (_lang === 'mermaid') {
const idx = mermaidBlocks.length;
mermaidBlocks.push('<div class="md-mermaid" data-code="' + escapeHtml(code.trim()) + '"></div>');
return '\x00MERMAID' + idx + '\x00';
}
const idx = codeBlocks.length;
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
return '\x00CODEBLOCK' + idx + '\x00';
});
// 2. Extract inline code
const inlineCodes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
const idx = inlineCodes.length;
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
return '\x00INLINE' + idx + '\x00';
});
// 2.5 Extract media blocks (<img>/<video>) → 结构化,后续渲染为 React 组件
const mediaBlocks: MediaInfo[] = [];
text = text.replace(/<(img|video)([^>]*?)\/?>/gi, (_m: string, tag: string, attrs: string) => {
const type = tag.toLowerCase() === 'img' ? 'img' : 'video';
const srcM = attrs.match(/src=["']([^"']+)["']/i);
const altM = attrs.match(/alt=["']([^"']*)["']/i);
mediaBlocks.push({ type, src: srcM ? srcM[1] : '', alt: altM ? altM[1] : '' });
return '\x00MEDIA' + (mediaBlocks.length - 1) + '\x00';
});
// 2.6 Normalize excessive blank lines in prose (code blocks already extracted)
text = text.replace(/\n{3,}/g, '\n\n').trim();
// 2.8 LaTeX 数学公式:$$...$$(块级)和 $...$(行内)
// 提取为占位符,渲染后用 KaTeX 处理
const mathBlocks: string[] = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: true, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<div class="md-math-block">${rendered}</div>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
text = text.replace(/\$([^\s$][^$]*[^\s$])\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: false, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<span class="md-math-inline">${rendered}</span>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
// 2.9 Strikethrough: ~~text~~ → <del>text</del>
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 2.10 Auto-link bare URLs: http(s)://... → <a>
text = text.replace(/(?<![="'>])https?:\/\/[^\s<>[\]()]+/g, (url: string) =>
`<a class="md-link" href="${url}" target="_blank" rel="noopener">${url}</a>`
);
// 3. Headers
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// 4. Horizontal rule
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
// 5. Blockquotes
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
// 6. Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Bold and italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
// 7.5 Extract Markdown tables after inline formatting so cells support bold/italic/links.
// 表格提取放在加粗/链接之后,避免单元格内 **xxx** 原样显示;同时压缩单元格内多余 <br>。
const tableBlocks: string[] = [];
text = text.replace(
/((?:^\|.+\|\r?\n)+^\|[\s\-:|]+\|\r?\n(?:^\|.+\|\r?\n?)+)/gm,
(_m: string, block: string) => {
const lines = block.trim().split(/\r?\n/).filter((l: string) => l.trim());
if (lines.length < 2) return block;
const headerCells = lines[0].split('|').slice(1, -1).map((c: string) => c.trim());
const alignCells = lines[1].split('|').slice(1, -1).map((c: string) => {
const t = c.trim();
const left = t.startsWith(':');
const right = t.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return 'left';
});
const normalizeCell = (cell: string) => {
return cell
.replace(/<br\s*\/?>\s*/gi, '<br/>')
.replace(/\n/g, '<br/>')
.replace(/(<br\/>){2,}/g, '<br/>')
.replace(/^(<br\/>)+|(<br\/>)+$/g, '');
};
const bodyRows = lines.slice(2).map((row: string) =>
row.split('|').slice(1, -1).map((c: string) => normalizeCell(c.trim()))
);
const thead = headerCells.map((c: string, i: number) =>
`<th style="text-align:${alignCells[i] || 'left'}">${normalizeCell(c)}</th>`
).join('');
const tbody = bodyRows.map((r: string[]) =>
'<tr>' + r.map((c: string, i: number) =>
`<td style="text-align:${alignCells[i] || 'left'}">${c}</td>`
).join('') + '</tr>'
).join('');
const html = `<table class="md-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
const idx = tableBlocks.length;
tableBlocks.push(html);
return '\x00TABLE' + idx + '\x00';
}
);
// 8. Lists
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
// 9. Convert double newlines to paragraph breaks, single newlines to spaces
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, ' ');
// 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MERMAID(\d+)\x00/g, (_m: string, idx: string) => mermaidBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
// 10.5 Apply syntax highlighting to code blocks + add copy button
text = text.replace(
/<pre class="md-code-block"><code class="md-code">([\s\S]*?)<\/code><\/pre>/g,
(_m: string, code: string) => {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let highlighted: string;
try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
return `<div class="md-code-wrapper">
<button class="md-code-copy" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('code').textContent);this.textContent='已复制';setTimeout(()=>this.textContent='复制',2000)">复制</button>
<pre class="md-code-block"><code class="md-code hljs">${highlighted}</code></pre>
</div>`;
}
);
// 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>';
text = text.replace(/<p class="md-p"><\/p>/g, '');
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<table[\s\S]*?<\/table>)<\/p>/g, '$1');
// 12. 拆分为文本片段与媒体组件
const parts = text.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => { return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/); const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) { if (m) {
const info = mediaBlocks[parseInt(m[1])]; const info = media[parseInt(m[1])];
if (info && info.src) return <ChatMedia key={'m' + i} info={info} />; if (info && info.src) return <ChatMedia key={'m' + i} info={info} />;
return null; return null;
} }
@@ -339,7 +172,7 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
if (!s.trim()) return null; if (!s.trim()) return null;
return <div key={'t' + i} className="md-text-seg" dangerouslySetInnerHTML={{ __html: s }} />; return <div key={'t' + i} className="md-text-seg" dangerouslySetInnerHTML={{ __html: s }} />;
}); });
}, [content]); }, [html, media]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -397,14 +230,6 @@ const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose
}; };
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ===== Time Formatting ===== // ===== Time Formatting =====
function formatTime(iso: string): string { function formatTime(iso: string): string {
return formatChatTime(iso); return formatChatTime(iso);
@@ -632,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 }>();
@@ -645,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[]>([]);
@@ -1070,11 +955,17 @@ const ChatPage: React.FC = () => {
createdAt: data.assistant_message.created_at || new Date().toISOString(), createdAt: data.assistant_message.created_at || new Date().toISOString(),
used_skills: data.assistant_message.used_skills || [], used_skills: data.assistant_message.used_skills || [],
used_mcp: data.assistant_message.used_mcp || [], used_mcp: data.assistant_message.used_mcp || [],
process_steps: data.assistant_message.process_steps || [],
}); });
} }
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);
@@ -1343,10 +1234,14 @@ const ChatPage: React.FC = () => {
// Real used_skills from API response // Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : []; const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : []; const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
const steps: any[] = (msg as any).process_steps || [];
// 模糊创作意图确认卡片 // 模糊创作意图确认卡片
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
@@ -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);
} }
+7
View File
@@ -62,6 +62,13 @@ export interface Message {
createdAt: string; createdAt: string;
used_skills?: string[]; used_skills?: string[];
used_mcp?: string[]; used_mcp?: string[];
process_steps?: Array<{
type: 'thinking' | 'skill' | 'mcp';
text?: string;
name?: string;
server?: string;
result?: string;
}>;
} }
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdownToHtml } from './markdown';
// 前台聊天 Markdown 渲染曾两次被 bug 击穿:
// 1) 段落内单个 \n 被转成 <br/>,导致 AI 回复被强制大量换行、内容撑成多行;
// 2) 表格在加粗/链接转换之前被提取,单元格内 **xxx** 原样显示、且 <br> 未压缩。
// 本测试固化「渲染管线」契约,防止回归。管线代码见 ./markdown.ts(从 ChatPage 抽出)。
describe('renderMarkdownToHtml', () => {
it('段落内单个 \\n 转为空格(而非 <br/>)', () => {
const { html } = renderMarkdownToHtml('第一行\n第二行');
expect(html).not.toContain('<br');
expect(html).toContain('第一行 第二行');
});
it('双换行转为段落分隔', () => {
const { html } = renderMarkdownToHtml('段一\n\n段二');
expect(html).toContain('段一</p><p class="md-p">段二');
});
it('3+ 连续空行压缩为 2 个(与 2 个空行结果一致)', () => {
const many = renderMarkdownToHtml('a\n\n\n\n\nb').html;
const few = renderMarkdownToHtml('a\n\nb').html;
expect(many).toBe(few);
expect(many).toContain('a</p><p class="md-p">b');
});
it('表格在加粗之后提取,单元格内的 **xxx** 被渲染为 <strong>', () => {
const input = '| 能力 | 说明 |\n| --- | --- |\n| **渠道排序** | 按优先级 |\n';
const { html } = renderMarkdownToHtml(input);
expect(html).toContain('<table class="md-table">');
expect(html).toContain('<th');
expect(html).toContain('<td');
expect(html).toContain('<strong>渠道排序</strong>');
});
it('单元格内连续 <br> 被压缩为单个', () => {
const input = '| 列 |\n| --- |\n| a<br/><br/>b |\n';
const { html } = renderMarkdownToHtml(input);
expect(html).toContain('a<br/>b');
expect(html).not.toContain('<br/><br/>');
});
it('无序列表渲染为 <ul><li>', () => {
const { html } = renderMarkdownToHtml('- a\n- b');
expect(html).toContain('<ul class="md-ul">');
expect(html).toContain('<li class="md-li">a</li>');
expect(html).toContain('<li class="md-li">b</li>');
});
it('一级标题渲染为 h2', () => {
const { html } = renderMarkdownToHtml('# Title');
expect(html).toContain('<h2 class="md-h2">Title</h2>');
});
it('代码块内容不被行内加粗转换', () => {
const { html } = renderMarkdownToHtml('```\n**not bold**\n```');
expect(html).toContain('<pre class="md-code-block">');
expect(html).not.toContain('<strong>not bold</strong>');
});
it('行内代码被抽取保护', () => {
const { html } = renderMarkdownToHtml('use `npm run` here');
expect(html).toContain('<code class="md-inline-code">npm run</code>');
});
it('[文本](url) 链接渲染', () => {
const { html } = renderMarkdownToHtml('[Google](https://g.com)');
expect(html).toContain('<a class="md-link" href="https://g.com"');
expect(html).toContain('Google');
});
it('媒体(<img>)以占位符保留并返回结构化 media 列表', () => {
const { html, media } = renderMarkdownToHtml('看这张图 <img src="https://x.com/a.png" alt="图">');
expect(media.length).toBe(1);
expect(media[0].type).toBe('img');
expect(media[0].src).toBe('https://x.com/a.png');
expect(media[0].alt).toBe('图');
expect(html).toContain('\x00MEDIA0\x00');
});
});
+205
View File
@@ -0,0 +1,205 @@
import katex from 'katex';
import hljs from 'highlight.js';
export interface MediaInfo {
type: 'img' | 'video';
src: string;
alt?: string;
}
export interface RenderResult {
html: string;
media: MediaInfo[];
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 将 Markdown 文本转为带占位符的 HTML 字符串。
*
* 设计契约(曾被线上 bug 击穿、用单测固化,见 markdown.test.ts):
* - 段落内【单个 \n 转为空格】而非 <br/>,避免 AI 回复被强制大量换行;
* - 【3+ 连续空行压缩为 2 个】(/\n{3,}/ -> \n\n),避免多余空白把内容撑成多行;
* - 【表格在加粗/链接转换之后提取】,单元格内的 **xxx** / [文本](url) 才能被渲染;
* 且单元格内连续 <br> 被压缩为单个(normalizeCell)。
*
* 媒体(<img>/<video>)以 \x00MEDIA<n>\x00 占位符保留在返回的 html 中,
* 由调用方(ChatPage 的 MarkdownContent)切分后渲染为 React 组件。
*/
export function renderMarkdownToHtml(content: string): RenderResult {
let text = content;
// 1. Extract code blocks first (protect from other replacements)
const codeBlocks: string[] = [];
const mermaidBlocks: string[] = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
if (_lang === 'mermaid') {
const idx = mermaidBlocks.length;
mermaidBlocks.push('<div class="md-mermaid" data-code="' + escapeHtml(code.trim()) + '"></div>');
return '\x00MERMAID' + idx + '\x00';
}
const idx = codeBlocks.length;
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
return '\x00CODEBLOCK' + idx + '\x00';
});
// 2. Extract inline code
const inlineCodes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
const idx = inlineCodes.length;
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
return '\x00INLINE' + idx + '\x00';
});
// 2.5 Extract media blocks (<img>/<video>) -> 结构化,后续渲染为 React 组件
const mediaBlocks: MediaInfo[] = [];
text = text.replace(/<(img|video)([^>]*?)\/?>/gi, (_m: string, tag: string, attrs: string) => {
const type = tag.toLowerCase() === 'img' ? 'img' : 'video';
const srcM = attrs.match(/src=["']([^"']+)["']/i);
const altM = attrs.match(/alt=["']([^"']*)["']/i);
mediaBlocks.push({ type, src: srcM ? srcM[1] : '', alt: altM ? altM[1] : '' });
return '\x00MEDIA' + (mediaBlocks.length - 1) + '\x00';
});
// 2.6 Normalize excessive blank lines in prose (code blocks already extracted)
text = text.replace(/\n{3,}/g, '\n\n').trim();
// 2.8 LaTeX 数学公式:$$...$$(块级)和 $...$(行内)
const mathBlocks: string[] = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: true, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<div class="md-math-block">${rendered}</div>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
text = text.replace(/\$([^\s$][^$]*[^\s$])\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: false, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<span class="md-math-inline">${rendered}</span>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
// 2.9 Strikethrough: ~~text~~ -> <del>text</del>
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 2.10 Auto-link bare URLs: http(s)://... -> <a>
text = text.replace(/(?<![="'>])https?:\/\/[^\s<>[\]()]+/g, (url: string) =>
`<a class="md-link" href="${url}" target="_blank" rel="noopener">${url}</a>`
);
// 3. Headers
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// 4. Horizontal rule
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
// 5. Blockquotes
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
// 6. Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Bold and italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
// 7.5 Extract Markdown tables after inline formatting so cells support bold/italic/links.
// 表格提取放在加粗/链接之后,避免单元格内 **xxx** 原样显示;同时压缩单元格内多余 <br>。
const tableBlocks: string[] = [];
text = text.replace(
/((?:^\|.+\|\r?\n)+^\|[\s\-:|]+\|\r?\n(?:^\|.+\|\r?\n?)+)/gm,
(_m: string, block: string) => {
const lines = block.trim().split(/\r?\n/).filter((l: string) => l.trim());
if (lines.length < 2) return block;
const headerCells = lines[0].split('|').slice(1, -1).map((c: string) => c.trim());
const alignCells = lines[1].split('|').slice(1, -1).map((c: string) => {
const t = c.trim();
const left = t.startsWith(':');
const right = t.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return 'left';
});
const normalizeCell = (cell: string) => {
return cell
.replace(/<br\s*\/?>\s*/gi, '<br/>')
.replace(/\n/g, '<br/>')
.replace(/(<br\/>){2,}/g, '<br/>')
.replace(/^(<br\/>)+|(<br\/>)+$/g, '');
};
const bodyRows = lines.slice(2).map((row: string) =>
row.split('|').slice(1, -1).map((c: string) => normalizeCell(c.trim()))
);
const thead = headerCells.map((c: string, i: number) =>
`<th style="text-align:${alignCells[i] || 'left'}">${normalizeCell(c)}</th>`
).join('');
const tbody = bodyRows.map((r: string[]) =>
'<tr>' + r.map((c: string, i: number) =>
`<td style="text-align:${alignCells[i] || 'left'}">${c}</td>`
).join('') + '</tr>'
).join('');
const html = `<table class="md-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
const idx = tableBlocks.length;
tableBlocks.push(html);
return '\x00TABLE' + idx + '\x00';
}
);
// 8. Lists
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
// 9. Convert double newlines to paragraph breaks, single newlines to spaces
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, ' ');
// 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MERMAID(\d+)\x00/g, (_m: string, idx: string) => mermaidBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
// 10.5 Apply syntax highlighting to code blocks + add copy button
text = text.replace(
/<pre class="md-code-block"><code class="md-code">([\s\S]*?)<\/code><\/pre>/g,
(_m: string, code: string) => {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let highlighted: string;
try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
return `<div class="md-code-wrapper">
<button class="md-code-copy" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('code').textContent);this.textContent='已复制';setTimeout(()=>this.textContent='复制',2000)">复制</button>
<pre class="md-code-block"><code class="md-code hljs">${highlighted}</code></pre>
</div>`;
}
);
// 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>';
text = text.replace(/<p class="md-p"><\/p>/g, '');
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<table[\s\S]*?<\/table>)<\/p>/g, '$1');
return { html: text, media: mediaBlocks };
}
+1 -1
View File
@@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS `skill_trajectory_links` (
`skill_name` VARCHAR(200) NOT NULL, `skill_name` VARCHAR(200) NOT NULL,
`user_id` VARCHAR(36) NOT NULL, `user_id` VARCHAR(36) NOT NULL,
`trajectory_id` VARCHAR(36), `trajectory_id` VARCHAR(36),
`atom_id` VARCHAR(36), `atom_id` VARCHAR(128),
`weightscore` INT DEFAULT 0, `weightscore` INT DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_stl_user_skill` (`user_id`, `skill_name`), INDEX `idx_stl_user_skill` (`user_id`, `skill_name`),
@@ -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',
+7
View File
@@ -0,0 +1,7 @@
-- 021: 加宽 skill_trajectory_links.atom_id 列
-- 背景:atom_id 实际值形如 "{trajectory_id}_atom_{i}"(UUID 36 + "_atom_" + 序号 ≈ 45 字符),
-- 原 VARCHAR(36) 过短导致写入时 DataError(1406) Data too long,并使会话进入 rolled-back 状态,
-- 级联触发 PendingRollbackError 与日志 trace_id KeyError 刷屏。
-- 本迁移幂等:重复执行 MODIFY 到相同类型为无害 no-op。
ALTER TABLE `skill_trajectory_links` MODIFY COLUMN `atom_id` VARCHAR(128) NULL;
+1 -1
View File
@@ -56,7 +56,7 @@ CREATE TABLE IF NOT EXISTS `distribution_licenses` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert seed data: sample distributions -- Insert seed data: sample distributions
INSERT INTO `distributions` (`id`, `name`, `description`, `brand_name`, `brand_color`, `provider`, `model`, `features`, `status`) VALUES INSERT IGNORE INTO `distributions` (`id`, `name`, `description`, `brand_name`, `brand_color`, `provider`, `model`, `features`, `status`) VALUES
('distro-starter', 'Starter Edition', '基础版发行包,适合个人开发者和小团队', 'AI Starter', '#6366f1', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec"]', 'active'), ('distro-starter', 'Starter Edition', '基础版发行包,适合个人开发者和小团队', 'AI Starter', '#6366f1', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec"]', 'active'),
('distro-enterprise', 'Enterprise Edition', '企业版发行包,包含完整 MCP 生态 + Agent 协作', 'AI Enterprise', '#f59e0b', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "knowledge_search", "agent_collab"]', 'active'), ('distro-enterprise', 'Enterprise Edition', '企业版发行包,包含完整 MCP 生态 + Agent 协作', 'AI Enterprise', '#f59e0b', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "knowledge_search", "agent_collab"]', 'active'),
('distro-devops', 'DevOps Edition', 'DevOps 专用发行包,CI/CD + 代码审查 + 部署', 'AI DevOps', '#10b981', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "automation", "webhook"]', 'active'); ('distro-devops', 'DevOps Edition', 'DevOps 专用发行包,CI/CD + 代码审查 + 部署', 'AI DevOps', '#10b981', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "automation", "webhook"]', 'active');
+8 -3
View File
@@ -84,6 +84,11 @@ CREATE TABLE IF NOT EXISTS usage_reports (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Add privacy_mode to distributions table -- 5. Add privacy_mode to distributions table
ALTER TABLE distributions -- MariaDB 11.8 不支持 ADD COLUMN IF NOT EXISTS 语法,改用 information_schema 预检 + 动态 PREPARE 实现幂等
ADD COLUMN IF NOT EXISTS privacy_mode ENUM('local', 'cloud', 'hybrid') NOT NULL DEFAULT 'cloud' SET @exist_pm := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'aiflow' AND TABLE_NAME = 'distributions' AND COLUMN_NAME = 'privacy_mode');
COMMENT 'Deployment mode for distribution' AFTER status; SET @sql_pm := IF(@exist_pm = 0,
"ALTER TABLE distributions ADD COLUMN privacy_mode ENUM('local', 'cloud', 'hybrid') NOT NULL DEFAULT 'cloud' COMMENT 'Deployment mode for distribution' AFTER status",
'SELECT 1');
PREPARE stmt_pm FROM @sql_pm;
EXECUTE stmt_pm;
DEALLOCATE PREPARE stmt_pm;