Compare commits

..
43 Commits
Author SHA1 Message Date
QihuAI Design 245484a0c5 phase0-final-rls-rbac-complete
CI / gate (push) Has been cancelled
2026-07-27 09:57:55 +08:00
QihuAI Design 2068421d41 phase0-fix-auth-order-db-migration 2026-07-27 09:54:03 +08:00
QihuAI Design 7b1941fdaf phase0-multi-tenant-rls-rbac 2026-07-27 09:44:06 +08:00
QihuAI Design f9c8bda6e0 docs-add-iteration-plan
CI / gate (push) Has been cancelled
2026-07-27 09:26:16 +08:00
QihuAI Design ec7de645f4 docs-add-architecture-blueprint
CI / gate (push) Has been cancelled
2026-07-27 09:10:11 +08:00
QihuAI Design efa63f9d25 fix-overflow-x-horizontal-scroll
CI / gate (push) Has been cancelled
2026-07-27 08:54:39 +08:00
QihuAI Design c624d8f37c mimo-token-plan-adapter
CI / gate (push) Has been cancelled
2026-07-26 21:47:48 +08:00
QihuAI Design bb32884ecb p2-mcp-schema-error-step-enhancements
CI / gate (push) Has been cancelled
2026-07-26 20:59:52 +08:00
QihuAI Design df67a298be mcp-tool-naming-fix
CI / gate (push) Has been cancelled
2026-07-26 15:37:17 +08:00
QihuAI Design e1e50e884e feat(chat): align AI tool-use with GPT-4.1/Anthropic standards -- Fix1: AGENT_GUIDE in system prompt (Persistence/Tool-calling/Planning), Fix2: install_app def improved, Fix3: max_tool_rounds 5->15
CI / gate (push) Has been cancelled
2026-07-26 08:35:42 +08:00
QihuAI Design a2504bce34 fix(chat): resolve Auto-route failover event-loop freeze (#812)
Root cause: ensure_structured_column() executed a synchronous blocking
engine.connect().execute(SELECT information_schema.columns ...) inside the
async request path (_build_chat_history / _maybe_compress_history), freezing
the uvicorn event loop and hanging all endpoints (~200s, recovered only by
docker restart).

Fix:
- chat_context.py: remove per-request ensure_structured_column() calls; run
  once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE()
  for PostgreSQL compatibility.
- main.py: add startup-only ensure_structured_column() hook (try/except).
- llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken
  endpoints fail fast instead of hanging the event loop.
- chat_service.py: run media generation in a background daemon thread with its
  own event loop (_launch_media_task_bg) so SSE end does not cancel image/video
  tasks (#811); remove TEMP faulthandler diagnostic.

Safe-align: persist accumulated live container changes (admin/*, knowledge,
market, models, config, observability, mcp_client, platform_knowledge,
im_bot_platform, chat_media, capability_registry, requirements) and add
workflow_templates.

Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with
concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
2026-07-26 00:35:35 +08:00
QihuAI Design 394a7786e0 chore: remove MySQL service from docker-compose (PostgreSQL migration complete)\n\n- Drop dead mysql service, mysql_data volume, and aiflow depends_on: mysql\n- Remove unused MYSQL_* env vars from aiflow service\n- Retain DATABASE_URL/POSTGRES_* pointing at qihu-aiflow-pg18\n- Closes the MySQL teardown started in task #802
CI / gate (push) Has been cancelled
2026-07-25 20:59:44 +08:00
dev 019e1151d0 fix(mcp_admin): add module-level import asyncio to fix 500 in test_mcp_connection (#766 root cause)
CI / gate (push) Has been cancelled
2026-07-25 14:03:36 +08:00
QihuAI Design 640edc4970 chore: normalize tsconfig.json line endings (file missed by prior commit)
CI / gate (push) Has been cancelled
The earlier renormalize commit (771c845) missed frontend/tsconfig.json; this
completes the LF normalization so all tracked text files are LF. .gitattributes
(* text=auto eol=lf) prevents future CRLF.
2026-07-25 12:33:53 +08:00
QihuAI Design 771c8456b8 chore: normalize line endings to LF via .gitattributes
Root-cause fix for mixed CRLF/LF in the repo (no .gitattributes previously).
Adds '* text=auto eol=lf' and explicit binary rules so all future checkouts
are LF. Renormalizes all tracked text files (no logical content change).
Backup tags: image qihuaiflow:backup-lineend-20260725-121632, git backup-lineend-pre-20260725-121632.
2026-07-25 12:16:39 +08:00
QihuAI Design 2a7bb875f9 fix(mcp/chat): root-cause fixes for AnySearch MCP test + URL app install
- mcp_admin.py: replace get_event_loop().run_until_complete() with asyncio.run() in _test_mcp_server worker-thread path (RuntimeError: no current event loop in thread). Fixes backend AnySearch 'test failed'.
- chat_skill.py: add synchronous _execute_install_app() that auto-detects skill/mcp/agent from a URL and creates MarketApp(+MCPServer is_enabled=True for mcp) + AppInstallation. No running-loop asyncio call (called sync from chat dispatch).
- chat_service.py: register 'install_app' function_definition and dispatch branch so the AI chat can install skills/mcp/agent by URL.
Image already persisted via docker export/import (qihuaiflow:with-deps=aaf6fe67cde8) because base image was pruned and docker commit failed.
2026-07-25 11:51:19 +08:00
QihuAI Design 256d9f33e6 chore(align): sync Git with running container as authoritative source
Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.

Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
  POST /app/api/chat/conversations/{conv_id}/messages/stream
  基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
  text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
  普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
  done 事件携带 message_id / content / process_steps / used_skills /
  used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
  (流式调用传 stream=True 导致 TypeError: multiple values for keyword),
  现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
  发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
  done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式

Alignment performed 2026-07-24 18:25:11. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-24 18:25:15 +08:00
QihuAI Design 3788a93425 chore(align): sync Git with running container as authoritative source
Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.

Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
  POST /app/api/chat/conversations/{conv_id}/messages/stream
  基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
  text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
  普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
  done 事件携带 message_id / content / process_steps / used_skills /
  used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
  (流式调用传 stream=True 导致 TypeError: multiple values for keyword),
  现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
  发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
  done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式

Alignment performed 2026-07-24 16:28:19. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-24 16:28:19 +08:00
QihuAI Design e8a1791c9f chore(align): sync Git with running container as authoritative source
Standards alignment (STANDARDS_AUDIT_2026-07-24): Phase 1 标准化核心 +
Phase 2 质量层 landed in the running container and captured in Git so the
three sources (container := image := Git) agree.

Backend (Phase 1 标准化核心):
- mcp_client.py: replaced hand-rolled JSON-RPC with official `mcp` SDK
  (ClientSession, streamable_http/sse transports, protocol auto-negotiation)
- core/otel.py + core/observability.py: standard OpenTelemetry SDK bridge
  (OTLP gRPC), wired to Grafana Alloy (qihu-aiflow-alloy:4317) which forwards
  metrics to Prometheus; emits aiflow_span_duration_ms / aiflow_span_count
- deployments: Grafana Alloy container + Prometheus remote-write receiver

Backend (Phase 2 质量层):
- core/rag_pipeline.py: standardized RAG (recursive overlap chunking +
  rerank hook) reusing pgvector_search
- core/llm_client.py: SDK-ified LLM + embeddings (aembed/embed) collapsing
  hand-rolled httpx calls; trust_env=False to bypass container proxy

Migrations:
- 018_normalize_marketapp_scene.sql: fixed capability allowlist
  (prevents capability wipe on restart)

Alignment performed 2026-07-24 12:18:51. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-24 12:18:52 +08:00
QihuAI Design acde481bed 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-24 07:44:19. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-24 07:44:19 +08:00
QihuAI Design 26075272d6 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 22:42:43. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-23 22:42:46 +08:00
QihuAI Design a1fd8b074d 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 22:16:24. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-23 22:16:25 +08:00
QihuAI Design 2721da5d4c feat: 思考过程展示优化 — 先展开再自动收拢
AiProcessBlock 改进:
- 超时从 2.6s 延至 6s,用户有充足时间阅读思考链路
- 新增 aiProcessPulse 脉冲高亮动画,强调'思考过程刚出炉'
- 展开时自动 scrollIntoView,确保不被正文遮挡
- 摘要文案改为'AI 思考 / 执行过程(N步 — 自动收拢中...)'

CSS 新增:
- .ai-process-fresh 脉冲动画 + summary 文字高亮
- @keyframes aiProcessPulse: 0→50%→100% 边框扩散+收拢
2026-07-23 22:00:49 +08:00
QihuAI Design 9d67b0544c 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 19:43:56. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-23 19:43:56 +08:00
QihuAI Design 4a95657030 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 16:04:25. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2026-07-23 16:04:26 +08:00
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
QihuAI Design 5def2697de fix(chat): image send 500 + table/newline formatting (v2026.07.20.1708)
CI / gate (push) Has been cancelled
2026-07-20 17:08:20 +08:00
73 changed files with 17790 additions and 1400 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Normalize all text files to LF on checkout/commit (root-cause fix for mixed line endings)
* text=auto eol=lf
# Binary assets: never normalize line endings or diff as text
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.otf binary
*.pdf binary
*.zip binary
*.tar binary
*.gz binary
*.bz2 binary
*.7z binary
*.pyc binary
*.so binary
*.dll binary
*.exe binary
*.db binary
*.sqlite binary
*.sqlite3 binary
*.wasm binary
*.mp4 binary
*.mp3 binary
*.avi binary
*.mov binary
+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
@@ -284,7 +284,7 @@ async def claim_achievement_reward(
# Mark reward as claimed # Mark reward as claimed
_execute_raw(db, _execute_raw(db,
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid", "UPDATE user_achievements SET reward_claimed = true, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id}) {"uid": user_id, "aid": ach_id})
return {"code": 0, "message": "奖励领取成功"} return {"code": 0, "message": "奖励领取成功"}
@@ -316,7 +316,7 @@ async def create_dashboard(
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]') config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
if is_default: if is_default:
db.execute(text("UPDATE analytics_dashboards SET is_default=0")) db.execute(text("UPDATE analytics_dashboards SET is_default=false"))
db.execute(text(""" db.execute(text("""
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by) INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
@@ -332,7 +332,7 @@ async def create_dashboard(
@router.delete("/dashboards/{dashboard_id}") @router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除仪表盘""" """删除仪表盘"""
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id}) db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=false"), {"id": dashboard_id})
db.commit() db.commit()
return {"code": 200, "data": {"message": "仪表盘已删除"}} return {"code": 200, "data": {"message": "仪表盘已删除"}}
@@ -445,7 +445,7 @@ async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
FROM billing_transactions FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed' WHERE transaction_type='recharge' AND status='completed'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) AND created_at >= NOW() - INTERVAL '7 DAY'
GROUP BY DATE(created_at) ORDER BY d GROUP BY DATE(created_at) ORDER BY d
""")).fetchall() """)).fetchall()
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d] daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
@@ -103,7 +103,7 @@ async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_curre
try: try:
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"] total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
today = _execute_raw(db, today = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"] "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURRENT_DATE")[0]["cnt"]
success_count = _execute_raw(db, success_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"] "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
failed_count = _execute_raw(db, failed_count = _execute_raw(db,
+1 -1
View File
@@ -703,7 +703,7 @@ echo "Installation complete!"
{d.get('version', '1.0.0')} {d.get('version', '1.0.0')}
## Installation ## Installation
Run `bash install.sh` to install. Run \"bash install.sh\" to install.
## License ## License
A valid license key is required. Contact your administrator for details. A valid license key is required. Contact your administrator for details.
+6 -6
View File
@@ -169,7 +169,7 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0} today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0}
# Total routing rules # Total routing rules
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt'] routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=true"))[0]._mapping['cnt']
return { return {
"success": True, "success": True,
@@ -202,7 +202,7 @@ async def get_dashboard(user=Depends(get_current_user)):
SUM(messages_out) as msg_out, SUM(messages_out) as msg_out,
SUM(unique_users) as users SUM(unique_users) as users
FROM im_bot_daily_stats FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) WHERE stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
GROUP BY stat_date GROUP BY stat_date
ORDER BY stat_date ORDER BY stat_date
""")) """))
@@ -217,7 +217,7 @@ async def get_dashboard(user=Depends(get_current_user)):
LEFT JOIN ( LEFT JOIN (
SELECT channel_id, SUM(messages_in + messages_out) as msg_count SELECT channel_id, SUM(messages_in + messages_out) as msg_count
FROM im_bot_daily_stats FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE stat_date >= CURRENT_DATE - INTERVAL '30 DAY'
GROUP BY channel_id GROUP BY channel_id
) s ON c.id = s.channel_id ) s ON c.id = s.channel_id
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
@@ -357,7 +357,7 @@ async def get_channel_detail(channel_id: str, user=Depends(get_current_user)):
SELECT stat_date, messages_in, messages_out, unique_users, error_count SELECT stat_date, messages_in, messages_out, unique_users, error_count
FROM im_bot_daily_stats FROM im_bot_daily_stats
WHERE channel_id = :cid WHERE channel_id = :cid
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) AND stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
ORDER BY stat_date ORDER BY stat_date
"""), {"cid": channel_id}) """), {"cid": channel_id})
channel['stats_7d'] = _rows_to_dict(stats) channel['stats_7d'] = _rows_to_dict(stats)
@@ -428,7 +428,7 @@ async def activate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
try: try:
_execute_raw(db, text( _execute_raw(db, text(
"UPDATE channels SET is_active=1, status='connected' WHERE id=:cid" "UPDATE channels SET is_active=true, status='connected' WHERE id=:cid"
), {"cid": channel_id}) ), {"cid": channel_id})
return {"success": True, "message": "Channel activated"} return {"success": True, "message": "Channel activated"}
except Exception as e: except Exception as e:
@@ -443,7 +443,7 @@ async def deactivate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
try: try:
_execute_raw(db, text( _execute_raw(db, text(
"UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid" "UPDATE channels SET is_active=false, status='disconnected' WHERE id=:cid"
), {"cid": channel_id}) ), {"cid": channel_id})
return {"success": True, "message": "Channel deactivated"} return {"success": True, "message": "Channel deactivated"}
except Exception as e: except Exception as e:
@@ -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:
+19 -99
View File
@@ -30,6 +30,7 @@ Phase 3.3: Manage MCP servers, discover tools/resources, test connections
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, Body from fastapi import APIRouter, Depends, HTTPException, Query, Body
@@ -842,110 +843,29 @@ def _test_mcp_server(server, db) -> dict:
if transport in ("streamable_http", "streamablehttp"): if transport in ("streamable_http", "streamablehttp"):
url = server.url url = server.url
if not url: if not url:
result["message"] = "Streamable HTTP endpoint URL is required" result["message"] = "Streamable HTTP endpoint URL is required"
else: else:
import httpx
try: try:
# Use the real MCP SDK to verify connectivity (same as chat flow).
# A naive HTTP GET/POST can give false negatives for endpoints
# that only accept proper MCP JSON-RPC over POST (e.g., AnySearch).
# A real streamable-http MCP endpoint accepts POST JSON-RPC. from ...mcp_client import list_mcp_tools as _lmt
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
import asyncio as _aio
# Some implementations also accept GET for SSE events. tools = _aio.run(_lmt(sd, timeout=15))
result["success"] = bool(tools)
result["details"]["tools_count"] = len(tools)
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
# Try GET first (cheapest), fall back to a minimal POST. result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
result["details"]["status_code"] = 200
if tools:
server.tools_count = len(tools)
resp = httpx.get(url, headers=req_headers, timeout=10.0, follow_redirects=True) server.status = "connected"
if resp.status_code == 405:
resp = httpx.post(
url,
headers=req_headers,
json={"jsonrpc": "2.0", "method": "initialize", "id": 1},
timeout=10.0,
follow_redirects=True,
)
# Any HTTP response (including 4xx) means the host is reachable.
result["success"] = resp.status_code < 500
result["message"] = f"HTTP responded with {resp.status_code}"
result["details"]["status_code"] = resp.status_code
result["details"]["content_type"] = resp.headers.get("content-type", "")
except httpx.ConnectError as e:
result["message"] = f"Connection refused - server may be down: {str(e)}"
except httpx.TimeoutException:
result["message"] = "Connection timed out"
except Exception as e: except Exception as e:
result["message"] = f"Connection failed: {str(e)}"
result["message"] = f"HTTP request failed: {str(e)}"
elif transport == "sse": elif transport == "sse":
@@ -3156,7 +3076,7 @@ async def test_mcp_connection(
result = _test_mcp_server(server, db) result = await asyncio.get_running_loop().run_in_executor(None, _test_mcp_server, server, db)
+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:
@@ -442,7 +442,7 @@ def revoke_license(
raise HTTPException(404, "License not found") raise HTTPException(404, "License not found")
_execute_raw(db, text( _execute_raw(db, text(
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid" "UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = false WHERE id = :lid"
), {"reason": reason, "lid": license_id}) ), {"reason": reason, "lid": license_id})
return {"code": 0, "message": "License revoked"} return {"code": 0, "message": "License revoked"}
@@ -623,7 +623,7 @@ def get_usage_stats(db: Session = Depends(get_db)):
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports" "SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
)) ))
today_reports = _execute_raw(db, text( today_reports = _execute_raw(db, text(
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()" "SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURRENT_DATE"
)) ))
by_type = _execute_raw(db, text( by_type = _execute_raw(db, text(
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type" "SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
+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
File diff suppressed because it is too large. Load diff
+121 -43
View File
@@ -3,7 +3,8 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func from sqlalchemy import and_, desc, asc, func, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import uuid import uuid
@@ -839,6 +840,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,
_AM.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
).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 +961,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 +979,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 +1027,7 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
"size": "1024x768", "size": "1024x1024",
@@ -960,7 +1036,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 +1068,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 +1168,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 +1237,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 +1265,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 +1498,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 90 media_kind = "video" if is_video else "image"
try:
result = await asyncio.wait_for(
_generate_media(db, ai_model, prompt, user_id),
timeout=timeout_seconds
)
# 构造模型路由:默认模型第一顺位,之后按路由策略逐一尝试
route = _build_media_route(db, media_kind, ai_model_id)
except asyncio.TimeoutError: if not route:
logger.warning(f"[_generate_media_task] media 生成超时 msg={ai_msg_id} is_video={is_video} timeout={timeout_seconds}s") result = f"⚠️ 未找到可用的{'视频' if is_video else '图像'}模型,请在后台「大模型库」中启用具备该能力的模型。"
else:
_media_name = "视频" if is_video else "图像" _media_name = "视频" if is_video else "图像"
result = f"⚠️ 媒体生成超时:{_media_name}生成耗时超过 {timeout_seconds} 秒,请稍后重试。" per_call = 300 if is_video else 180
except Exception as e: route_timeout = min(per_call * len(route) + 30, 900 if is_video else 600)
logger.warning(f"[_generate_media_task] media 生成异常 msg={ai_msg_id}: {e}", exc_info=True) try:
result = f"⚠️ 媒体生成失败:{str(e)[:200]}" result = await asyncio.wait_for(
_run_media_route(db, route, prompt, user_id, media_kind),
timeout=route_timeout,
)
except asyncio.TimeoutError:
logger.warning(f"[_generate_media_task] media 路由全部超时 msg={ai_msg_id} is_video={is_video} timeout={route_timeout}s")
result = f"⚠️ {_media_name}生成超时:已依次尝试 {len(route)} 个模型仍失败,请稍后重试。"
except Exception as e:
logger.warning(f"[_generate_media_task] media 路由异常 msg={ai_msg_id}: {e}", exc_info=True)
result = f"⚠️ {_media_name}生成失败:{str(e)[:200]}"
@@ -1494,11 +1567,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
+148 -1
View File
@@ -1272,7 +1272,7 @@ def _execute_skill_tool(
return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。" return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。如有已加载的技能,可使用其名称直接调用。"
@@ -1593,3 +1593,150 @@ def _execute_skill_tool(
return result return result
def _generate_install_fallback_skill(name: str, description: str) -> str:
"""install_app 兜底:无法识别为 SKILL.md 时,生成一份可用技能定义。"""
desc = (description or "").strip() or f"{name} 相关能力"
return (
f"---\n"
f"name: {name}\n"
f"description: {desc}\n"
f"category: other\n"
f"---\n\n"
f"# {name}\n\n"
f"{desc}\n\n"
f"## 使用建议\n"
f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
f"- 输出结构清晰、可直接使用。\n"
f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
)
def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto") -> str:
"""前台 AI 聊天:通过 URL 安装 skill / mcp / agent(根治问题 #3)。
逻辑对齐 market.upload_app,但本函数不引用 market 私有函数以避免循环 import:
- 同步 httpx.Client(trust_env=False, 显式 proxy) 抓取 URL 内容(与 _fetch_skill_content 同款)
- 自动识别类型:mcp / agent / skill
- 建 MarketApp(+ mcp 时 MCPServer + AppInstallation),自动标记为已安装
关键约束:本函数在聊天主事件循环中【同步】执行,禁止调用 asyncio.run
(会抛 "cannot be called from a running event loop")。因此 MCP 连接测试交由后台
「测试」按钮在 worker 线程异步执行;新建 MCPServer 直接 is_enabled=True 即可被
聊天链路 _resolve_active_mcp_servers 使用(该解析仅过滤 is_enabled)。
"""
if not url or not str(url).strip().lower().startswith("http"):
return "请提供有效的安装地址(URL),例如 GitHub 仓库、mcp.so 或 modelscope 链接。"
url = str(url).strip()
proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None
try:
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as client:
r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; QihuAI/1.0)"})
content = r.text if r.status_code == 200 else ""
except Exception as e:
return f"无法访问该地址:{str(e)[:200]}"
_u = url.lower()
_c = (content or "").lower()
detected = "skill"
if app_type and app_type != "auto":
detected = app_type
else:
if "mcpservers" in _c or '"mcpservers"' in (content or "").lower() or (
"url" in _c and ("transport" in _c or '"type"' in _c)
):
detected = "mcp"
elif any(k in _u for k in ["dify", "n8n", "coze"]) or "agent_endpoint" in _c:
detected = "agent"
else:
detected = "skill"
import re as _re
_name = _re.sub(r"^https?://", "", url).strip("/").split("/")[-1] or "imported_app"
_name = _name[:120]
_desc = f"通过链接导入:{url}"
_dup = db.query(MarketApp).filter(
func.lower(MarketApp.name) == _name.lower(),
MarketApp.type == detected,
).first()
if _dup:
return f"「{_name}」({detected}) 已存在,无需重复安装(ID: {_dup.id})。可在应用市场直接使用。"
app = MarketApp(
id=gen_uuid(),
name=_name,
description=_desc,
type=detected,
source="user",
category="other",
tags=None,
price=0,
install_count=0,
rating_avg=0,
review_count=0,
publisher_id=user_id,
status="published",
created_at=datetime.now(),
updated_at=datetime.now(),
)
if detected == "mcp":
from ..api.admin.mcp_admin import parse_mcp_config
parsed = parse_mcp_config(content)
_murl = parsed.get("url") or url
app.skill_content = content
app.mcp_transport = parsed.get("transport_type", "streamable_http")
db.add(app)
db.commit()
db.refresh(app)
_hdr = json.dumps(parsed.get("headers") or {}, ensure_ascii=False)
server = MCPServer(
id=app.id,
name=app.name,
description=app.description or "",
transport_type=parsed.get("transport_type", "streamable_http"),
command="",
args="{}",
env_vars="{}",
url=_murl,
headers=_hdr,
is_enabled=True,
status="disconnected",
tools_count=0,
resources_count=0,
created_at=app.created_at,
updated_at=app.updated_at,
)
db.add(server)
db.commit()
elif detected == "agent":
app.agent_endpoint = url
if "dify" in _u:
app.platform = "dify"
elif "n8n" in _u:
app.platform = "n8n"
elif "coze" in _u:
app.platform = "coze"
app.skill_content = content if "{" in content else None
db.add(app)
db.commit()
db.refresh(app)
else:
_is_skillmd = ("name:" in _c and "description:" in _c) or _u.endswith(".md")
app.skill_content = content if _is_skillmd else _generate_install_fallback_skill(_name, _desc)
db.add(app)
db.commit()
db.refresh(app)
if user_id:
_inst = db.query(AppInstallation).filter(
AppInstallation.user_id == user_id,
AppInstallation.app_id == app.id,
).first()
if not _inst:
db.add(AppInstallation(user_id=user_id, app_id=app.id))
db.commit()
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"
+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:
+7 -7
View File
@@ -109,20 +109,20 @@ def _ensure_table(db):
"""确保 im_bot_platform_account 表存在(首次调用时创建)。""" """确保 im_bot_platform_account 表存在(首次调用时创建)。"""
db.execute(text(""" db.execute(text("""
CREATE TABLE IF NOT EXISTS im_bot_platform_account ( CREATE TABLE IF NOT EXISTS im_bot_platform_account (
id INT AUTO_INCREMENT PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id VARCHAR(64) DEFAULT NULL, user_id VARCHAR(64) DEFAULT NULL,
platform VARCHAR(32) NOT NULL, platform VARCHAR(32) NOT NULL,
status VARCHAR(32) DEFAULT 'disconnected', status VARCHAR(32) DEFAULT 'disconnected',
creds MEDIUMTEXT NULL, creds TEXT NULL,
send_url VARCHAR(512) NULL, send_url VARCHAR(512) NULL,
api_base VARCHAR(512) NULL, api_base VARCHAR(512) NULL,
bot_token VARCHAR(512) NULL, bot_token VARCHAR(512) NULL,
webhook_token VARCHAR(64) NULL, webhook_token VARCHAR(64) NULL,
last_error MEDIUMTEXT NULL, last_error TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_platform (user_id, platform) UNIQUE (user_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci )
""")) """))
db.commit() db.commit()
+1 -2
View File
@@ -458,9 +458,8 @@ def _wechat_receiver_loop(account_id: int):
except Exception: except Exception:
pass pass
if ret in (-2, -14): if ret in (-2, -14):
# 会话/鉴权失效:标记需重扫并停止接收线程 # 会话/鉴权失效:标记需重扫但继续循环,让下一轮 poll 自然检测过期
_mark_wechat_expired(d["id"]) _mark_wechat_expired(d["id"])
return
time.sleep(1) time.sleep(1)
except Exception: except Exception:
time.sleep(5) time.sleep(5)
+121 -73
View File
@@ -4,13 +4,16 @@ IM Bot 真实 LLM 调用。
原实现位于 channels.py(通用频道管理模块),该模块已移除。 原实现位于 channels.py(通用频道管理模块),该模块已移除。
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。 为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
行为(对齐 AI 聊天): 行为(对齐 AI 聊天 — 2026-07-23 同步至最新):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由), - Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对"制作图片"这类请求只回文本。 不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能, - Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。 多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败; - 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。 媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
- 2026-07-23:对齐 Web Chat 最新能力:
* 模型 failover(Auto 模式自动切换备选模型)
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
""" """
import asyncio import asyncio
import json import json
@@ -21,6 +24,7 @@ from typing import Optional
from urllib.parse import urlparse as _urlparse from urllib.parse import urlparse as _urlparse
import httpx import httpx
from app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
@@ -83,7 +87,7 @@ def _run_async(coro, timeout=None):
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str): def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。 """IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。 此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。 媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
""" """
if not user_id or not ai_model: if not user_id or not ai_model:
@@ -112,7 +116,7 @@ def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, promp
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict": def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。""" """调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
try: try:
from app.api.chat import ( from app.api.chat import (
_generate_media, _generate_media,
@@ -151,19 +155,23 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
return s return s
return "(媒体生成未能返回有效内容,请重试)" return "(媒体生成未能返回有效内容,请重试)"
# ===== 2) 文本回复 + 技能自动发现(Issue 8)===== # ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
ai_model = resolve_auto_model("text", db) from ..platform_knowledge import get_platform_knowledge
if not ai_model:
return "(未配置默认文本模型,无法生成回复)"
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
return "(模型未配置 API Key 或地址,无法生成回复)"
# 自动发现本账号已安装且平台已部署的技能 # 构建候选模型列表(对齐 Web Chat failover)
ai_model = resolve_auto_model("text", db)
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
# Auto 模式附加备选(IM Bot 始终 Auto)
_fallbacks = db.query(AIModel).filter(
AIModel.is_enabled == True,
AIModel.id != (ai_model.id if ai_model else ""),
).order_by(AIModel.is_default.desc()).limit(4).all()
_try_models.extend([m for m in _fallbacks if m not in _try_models])
if not _try_models:
return "(未配置可用文本模型,无法生成回复)"
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
skills = [] skills = []
if user_id: if user_id:
try: try:
@@ -171,64 +179,104 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
except Exception: except Exception:
skills = [] skills = []
system_prompt, function_definitions = _build_system_prompt( # 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
skills=skills, kb_context = ""
agents=[], try:
kb_context="", pk = get_platform_knowledge(db=db)
user_content=prompt, if pk:
mcp=[], kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
auto_discovered=bool(skills), except Exception:
) pass
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
max_rounds = 3 # 尝试每个候选模型
ai_content = "" _retry_log = []
for _ in range(max_rounds): for _try_model in _try_models:
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048} ai_model = _try_model
if function_definitions and skills:
request_body["tools"] = [ provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
{"type": "function", "function": fd} for fd in function_definitions api_key = ai_model.api_key or (provider.api_key if provider else None)
] api_url = ai_model.endpoint
request_body["tool_choice"] = "auto" if not api_url and provider and provider.api_base_url:
with httpx.Client( api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
timeout=ai_model.timeout or 120, if not (api_key and api_url):
proxy=_proxy_for_url(api_url), _retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
trust_env=False, continue
) as client:
resp = client.post( system_prompt, function_definitions = _build_system_prompt(
api_url, skills=skills,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, agents=[],
json=request_body, kb_context=kb_context,
) user_content=prompt,
if resp.status_code != 200: mcp=[],
return f"(AI 模型调用失败 HTTP {resp.status_code})" auto_discovered=bool(skills),
data = resp.json() )
message = data.get("choices", [{}])[0].get("message", {}) messages = [
tool_calls = message.get("tool_calls", []) {"role": "system", "content": system_prompt},
if tool_calls and skills: {"role": "user", "content": prompt},
messages.append({ ]
"role": "assistant",
"content": message.get("content") or "", # 尝试调用
"tool_calls": tool_calls, try:
}) max_rounds = 3
for tc in tool_calls: ai_content = ""
func_name = tc.get("function", {}).get("name", "") _http_ok = True
try: for round_num in range(max_rounds):
func_args = json.loads(tc.get("function", {}).get("arguments", "{}")) request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
except Exception: if function_definitions and skills:
func_args = {"input": tc.get("function", {}).get("arguments", "")} request_body["tools"] = [
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt) {"type": "function", "function": fd} for fd in function_definitions
messages.append({ ]
"role": "tool", request_body["tool_choice"] = "auto"
"tool_call_id": tc.get("id", "call_1"), try:
"content": tool_result, data = chat_complete(
}) api_url,
continue api_key,
ai_content = message.get("content", "") request_body,
break proxy=_proxy_for_url(api_url),
return ai_content or "(AI 回复为空)" timeout=ai_model.timeout or 120,
)
except Exception as _exc:
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
_http_ok = False
break
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
# 检查本轮调用是否成功
if _http_ok and ai_content:
return ai_content or "(AI 回复为空)"
# 本轮失败:记录日志,切换备选
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
continue
except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
continue
# 全部失败
_summary = "; ".join(_retry_log[-3:])
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
except Exception as e: except Exception as e:
return f"(AI 回复生成失败:{e})" return f"(AI 回复生成失败:{e})"
+101 -2
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
@@ -501,8 +502,8 @@ def _parse_llm_graph_response(content: str):
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
# 方式2: 提取```json ... ```代码块 # 方式2: 提取\"\"\"json ... \"\"\"代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', content) json_match = re.search(r'\"\"\"(?:json)?\s*([\s\S]*?)\"\"\"', content)
if json_match: if json_match:
try: try:
result = json.loads(json_match.group(1)) result = json.loads(json_match.group(1))
@@ -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 = {
+288 -22
View File
@@ -40,29 +40,46 @@ 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()
# 1) fenced code block # 1) fenced code block
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE) fences = re.findall(r"\"\"\"(?:markdown|md|yaml|json)?\s*\n(.*?)\"\"\"", html, re.DOTALL | re.IGNORECASE)
for f in fences: for f in fences:
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80: if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
return f.strip() return f.strip()
@@ -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,9 +915,12 @@ 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:
app.skill_content = content # 兜底:抓取/粘贴均未提供内容时,基于名称/描述/分类生成可用 SKILL.md,
db.commit() # 避免空壳技能(#681 根因修复:保证再次导入也不会内容为空)
content = _generate_skill_content(app.name, app.description, req.category, app.capability)
app.skill_content = content
db.commit()
elif app.type == "agent": elif app.type == "agent":
if req.skill_content: if req.skill_content:
app.skill_content = req.skill_content app.skill_content = req.skill_content
+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]}
+26 -1
View File
@@ -12,12 +12,35 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
# Phase0: 5-level RBAC
ROLE_HIERARCHY = {
"super_admin": 100,
"admin": 80,
"developer": 50,
"user": 30,
"guest": 10,
}
def has_role(user_role: str, required: str) -> bool:
return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required, 0)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
security = HTTPBearer() security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False) security_optional = HTTPBearer(auto_error=False)
async def get_current_tenant(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
return payload.get("tenant_id")
except JWTError:
return None
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""使用 bcrypt 直接生成密码哈希""" """使用 bcrypt 直接生成密码哈希"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
@@ -35,8 +58,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return False return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, tenant_id: str = None) -> str:
to_encode = data.copy() to_encode = data.copy()
if tenant_id:
to_encode["tenant_id"] = tenant_id
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+3 -4
View File
@@ -98,12 +98,11 @@ 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, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
m = db.query(AIModel).filter( m = db.query(AIModel).filter(
AIModel.is_enabled == True, AIModel.is_enabled == True,
AIModel.capabilities.op("JSON_CONTAINS")( AIModel.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
AIModel.capabilities, f'"{capability}"'
),
).first() ).first()
if m: if m:
return m return m
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -15,7 +15,12 @@ if not (Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()):
load_dotenv(env_path, override=False) load_dotenv(env_path, override=False)
# 数据库配置 # 数据库配置
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4" # PostgreSQL preferred when DATABASE_URL is provided (production); else legacy MySQL builder (local/dev + rollback)
_DATABASE_URL_ENV = os.getenv("DATABASE_URL", "")
if _DATABASE_URL_ENV:
DATABASE_URL = _DATABASE_URL_ENV
else:
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
# Redis配置 # Redis配置
REDIS_HOST = os.getenv("REDIS_HOST", "localhost") REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
@@ -128,7 +133,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")
+280
View File
@@ -0,0 +1,280 @@
"""Shared, standards-based LLM client for web chat + IM bot.
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
(\"openai.AsyncOpenAI\")——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(\"/v1/chat/completions\"),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(\"stream=True\");
* 结构化的 tool_calls / usage 解析;
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与
IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」
红线在模型层的落地。
API:
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- stream=False -> dict,形状对齐上游 \"data\" 响应:
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
"""
from __future__ import annotations
import httpx
import asyncio
from openai import AsyncOpenAI, OpenAI
def _base_url(api_url: str) -> str:
"""从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。"""
u = (api_url or "").rstrip()
for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"):
if u.endswith(suf):
return u[: -len(suf)]
return u
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> AsyncOpenAI:
base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
# 故障快速失败(全栈统一,覆盖 web chat / IM bot / embedding):
# connect 封顶 10s —— 坏端点不再让请求挂死事件循环;
# read/write 封顶 30s —— 长流式回复只要持续有分块即不受影响;
# 保留数值型 timeout 接口,旧调用方无需改动。
_num = float(timeout) if isinstance(timeout, (int, float)) else 120.0
_to = httpx.Timeout(
connect=min(10.0, _num),
read=min(30.0, _num),
write=min(30.0, _num),
pool=10.0,
)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return AsyncOpenAI(
base_url=base,
api_key=api_key or "EMPTY",
timeout=_to,
http_client=http_client,
default_headers=_hdrs if _hdrs else None,
)
def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
kw = {
"model": request_body.get("model"),
"messages": request_body.get("messages", []),
"stream": stream,
}
if request_body.get("temperature") is not None:
kw["temperature"] = request_body["temperature"]
if request_body.get("top_p") is not None:
kw["top_p"] = request_body["top_p"]
if request_body.get("max_tokens") is not None:
kw["max_tokens"] = request_body["max_tokens"]
if request_body.get("stop") is not None:
kw["stop"] = request_body["stop"]
tools = request_body.get("tools")
if tools:
kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto")
return kw
def _normalize_tool_calls(tcs):
if not tcs:
return None
out = []
for tc in tcs:
out.append(
{
"id": getattr(tc, "id", None),
"type": "function",
"function": {
"name": getattr(tc.function, "name", None),
"arguments": getattr(tc.function, "arguments", "") or "",
},
}
)
return out
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False, auth_header=None):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream:
async def _gen():
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
_content_parts = []
_reasoning_parts = []
_tool_acc = {} # index -> {id, type, name, arguments}
try:
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
gen = await asyncio.wait_for(
client.chat.completions.create(
**_create_kwargs(request_body, stream=True)
),
timeout=_to,
)
_it = gen.__aiter__()
while True:
try:
chunk = await asyncio.wait_for(_it.__anext__(), timeout=_to)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise TimeoutError("stream chunk read timeout")
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None)
if rc:
_reasoning_parts.append(rc)
yield ("reasoning", rc)
ct = getattr(delta, "content", None)
if ct:
_content_parts.append(ct)
yield ("content", ct)
# 累积 OpenAI 流式 tool_calls(供后续工具循环执行)
tcs = getattr(delta, "tool_calls", None)
if tcs:
for tc in tcs:
_idx = getattr(tc, "index", 0)
_slot = _tool_acc.setdefault(
_idx, {"id": None, "type": "function", "name": "", "arguments": ""}
)
_tcid = getattr(tc, "id", None)
if _tcid:
_slot["id"] = _tcid
_fn = getattr(tc, "function", None)
if _fn is not None:
_fn_name = getattr(_fn, "name", None)
if _fn_name:
_slot["name"] += _fn_name
_fn_args = getattr(_fn, "arguments", None)
if _fn_args:
_slot["arguments"] += _fn_args
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
_tool_calls = None
if _tool_acc:
_tool_calls = []
for _i in sorted(_tool_acc.keys()):
_s = _tool_acc[_i]
_tool_calls.append({
"id": _s["id"],
"type": "function",
"function": {"name": _s["name"], "arguments": _s["arguments"]},
})
yield ("message", {
"content": "".join(_content_parts),
"tool_calls": _tool_calls,
"reasoning_content": "".join(_reasoning_parts) or None,
})
return _gen()
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
resp = await asyncio.wait_for(
client.chat.completions.create(**_create_kwargs(request_body)),
timeout=_to,
)
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client, default_headers=_hdrs if _hdrs else None)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
with client:
resp = client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
# ---------------------------------------------------------------------------
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
# ---------------------------------------------------------------------------
def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]}
async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
"""
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(60.0, float(timeout) if isinstance(timeout, (int, float)) else 60.0)
resp = await asyncio.wait_for(
client.embeddings.create(**_embed_kwargs(input, model)),
timeout=_to,
)
return [d.embedding for d in resp.data]
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
with client:
resp = client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]
+36 -7
View File
@@ -1,22 +1,26 @@
""" """
Qihu-AIFlow 统一可观测基座 (Phase 2.1) Qihu-AIFlow 统一可观测基座 (Phase 2.1 + Phase 1.I OTel 增强)
===================================== =============================================================
提供贯穿一次请求的全局链路 ID(trace_id)与结构化日志能力, 提供贯穿一次请求的全局链路 ID(trace_id)与结构化日志能力,
为后续重构(上下文引擎、媒体/LLM 成本归因)建立回归基线。 为后续重构(上下文引擎、媒体/LLM 成本归因)建立回归基线。
设计原则(Strangler Fig 增量): 设计原则(Strangler Fig 增量):
- 纯增量埋点,不改任何既有接口的返回结构。 - 纯增量埋点,不改任何既有接口的返回结构。
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的 - 向后兼容现有 \"logging.getLogger(__name__)\" 用法:trace_id 由 root logger 的
TraceIdFilter 自动注入,调用方无需改动。 TraceIdFilter 自动注入,调用方无需改动。
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。 - 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
Phase 1.I 增强:span() 在保持原有日志计时的同时,额外嵌套一个标准 OpenTelemetry
span(经 app.core.otel 桥接,OTLP 导出到 Grafana)。OTel 失败时自动降级为 no-op,
绝不改变既有日志行为与请求链路。
使用方式: 使用方式:
from app.core.observability import set_trace_id, get_trace_id, span from app.core.observability import set_trace_id, get_trace_id, span
# 在中间件里为每个请求注入: # 在中间件里为每个请求注入:
set_trace_id(request_id) set_trace_id(request_id)
# 在关键调用处打计时 span(自动带 trace_id): # 在关键调用处打计时 span(自动带 trace_id + OTel span):
async with span("chat.send_message", conv_id=conv_id): async with span("chat.send_message", conv_id=conv_id):
return await _send_message_core(...) return await _send_message_core(...)
""" """
@@ -26,6 +30,8 @@ import uuid
import json import json
from contextvars import ContextVar from contextvars import ContextVar
from app.core.otel import current_span_cm, record_span_metrics
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# trace_id:贯穿一次请求(含其内所有子调用/协程)的全局链路 ID # trace_id:贯穿一次请求(含其内所有子调用/协程)的全局链路 ID
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -52,7 +58,7 @@ def get_trace_id() -> str:
class TraceIdFilter(logging.Filter): class TraceIdFilter(logging.Filter):
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。 """为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承, 挂载到 root logger 后,所有 \"getLogger(__name__)\" 的子 logger 都会继承,
从而让全平台既有日志自动带上链路 ID,零侵入。 从而让全平台既有日志自动带上链路 ID,零侵入。
""" """
@@ -103,10 +109,10 @@ def get_logger(name: str | None = None) -> logging.Logger:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# span:结构化计时上下文管理器(耗时 + trace_id + 自定义字段) # span:结构化计时上下文管理器(耗时 + trace_id + 自定义字段 + OTel 嵌套)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _Span: class _Span:
"""同步/异步通用的计时 span。退出时记录 elapsed_ms 与状态。""" """同步/异步通用的计时 span。退出时记录 elapsed_ms 与状态,并结束嵌套的 OTel span。"""
def __init__(self, name: str, **fields): def __init__(self, name: str, **fields):
self.name = name self.name = name
@@ -114,6 +120,7 @@ class _Span:
self._logger = logging.getLogger("aiflow.span") self._logger = logging.getLogger("aiflow.span")
self._start = None self._start = None
self.elapsed_ms = 0.0 self.elapsed_ms = 0.0
self._otel_cm = None # 嵌套的 OTel span 上下文管理器
def _enter(self): def _enter(self):
self._start = time.perf_counter() self._start = time.perf_counter()
@@ -121,6 +128,10 @@ class _Span:
f"SPAN_START {self.name}", f"SPAN_START {self.name}",
extra={"span": self.name, "event": "start", "trace_id": get_trace_id(), **self.fields}, extra={"span": self.name, "event": "start", "trace_id": get_trace_id(), **self.fields},
) )
# OTel 嵌套 span(失败安全:current_span_cm 返回 None 时 no-op)
self._otel_cm = current_span_cm(self.name, self.fields)
if self._otel_cm is not None:
self._otel_cm.__enter__()
return self return self
def _exit(self, exc_type, exc, tb): def _exit(self, exc_type, exc, tb):
@@ -137,6 +148,24 @@ class _Span:
**self.fields, **self.fields,
}, },
) )
# 结束嵌套的 OTel span(失败安全)
if self._otel_cm is not None:
try:
from opentelemetry import trace as _ot
if exc_type is not None:
self._otel_cm.set_status(
_ot.trace.Status(_ot.trace.StatusCode.ERROR, str(exc_type.__name__))
)
else:
self._otel_cm.set_status(_ot.trace.Status(_ot.trace.StatusCode.OK))
self._otel_cm.__exit__(exc_type, exc, tb)
except Exception:
pass
# 记录 span 指标(耗时直方图 + 计数),供 Grafana 出图(失败安全)
try:
record_span_metrics(self.name, self.elapsed_ms, status)
except Exception:
pass
return False # 不吞掉异常 return False # 不吞掉异常
# 同步协议 # 同步协议
+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],
+131
View File
@@ -0,0 +1,131 @@
"""
OpenTelemetry 桥接 (Phase 1.I 标准化)
=====================================
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §I):
- 标准:OpenTelemetry SDK + OTLP gRPC exporter —— 云原生可观测事实标准。
- traces:span 经 observability.span() 嵌套导出(见 observability.py)。
- metrics:MeterProvider 经 OTLP 导出 span 耗时直方图 + 调用计数,供 Grafana 出图。
- 失败安全(生产红线):若 OTLP 端点不可达 / 初始化异常,自动降级为 no-op,
绝不阻断业务请求、不产生未捕获异常。
- 门控:OTEL_ENABLED=1 默认开启;置 0 则零开销、不走 OTel。
- 发射目标:OTEL_EXPORTER_OTLP_ENDPOINT,默认 http://qihu-aiflow-alloy:4317
(经 Grafana Alloy 桥接 OTLP→Prometheus,再由 Prometheus 喂给 Grafana 出图)。
对外 API:
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
get_tracer() 返回 tracer(未启用/失败则 None)
get_meter() 返回 meter(未启用/失败则 None)
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套)
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数)
"""
import os
import logging
logger = logging.getLogger(__name__)
_ENABLED = os.getenv("OTEL_ENABLED", "1") not in ("0", "false", "False", "")
_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://qihu-aiflow-alloy:4317").rstrip("/")
if not _ENDPOINT.startswith("http://") and not _ENDPOINT.startswith("https://"):
_ENDPOINT = "http://" + _ENDPOINT
_tracer = None
_meter = None
_initialized = False
_duration_hist = None
_span_counter = None
def init_otel() -> None:
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
global _tracer, _meter, _initialized
if _initialized:
return
_initialized = True
if not _ENABLED:
logger.info("[otel] disabled (OTEL_ENABLED=0)")
return
try:
from opentelemetry import trace, metrics
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
resource = Resource.create({ResourceAttributes.SERVICE_NAME: "qihu-aiflow"})
# --- traces ---
trace_provider = TracerProvider(resource=resource)
trace_provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=_ENDPOINT, insecure=True))
)
trace.set_tracer_provider(trace_provider)
_tracer = trace.get_tracer("qihu-aiflow")
# --- metrics ---
metric_reader = PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint=_ENDPOINT, insecure=True)
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
_meter = metrics.get_meter("qihu-aiflow")
logger.info(f"[otel] tracer+meter ready -> {_ENDPOINT}")
except Exception as e: # pragma: no cover - 降级保护
logger.warning(f"[otel] init failed, degraded to no-op: {e}")
_tracer = None
_meter = None
def get_tracer():
"""返回 tracer;首次调用触发 init。未启用/失败返回 None。"""
if _tracer is None and _ENABLED:
init_otel()
return _tracer
def get_meter():
"""返回 meter;首次调用触发 init。未启用/失败返回 None。"""
if _meter is None and _ENABLED:
init_otel()
return _meter
def current_span_cm(name: str, attributes: dict | None = None):
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
tr = get_tracer()
if tr is None:
return None
try:
return tr.start_as_current_span(name, attributes=attributes or {})
except Exception as e: # pragma: no cover
logger.warning(f"[otel] start_span failed: {e}")
return None
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
m = get_meter()
if m is None:
return
global _duration_hist, _span_counter
try:
if _duration_hist is None:
_duration_hist = m.create_histogram(
"aiflow_span_duration_ms", "Span duration in milliseconds"
)
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
attrs = {"span": name, "status": status}
_duration_hist.record(elapsed_ms, attrs)
_span_counter.add(1, attrs)
except Exception: # pragma: no cover
pass
+97
View File
@@ -0,0 +1,97 @@
"""标准 RAG 管线 (Phase 2.C 标准化)
====================================
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §C):
- 对齐 LlamaIndex / LangChain 的管线形态:load -> chunk(overlap) -> embed -> store -> retrieve -> rerank。
- 复用现有 pgvector 后端(document_embeddings 表 + ivfflat)与共享 embeddings 客户端
(app.core.llm_client.aembed);嵌入维度(1024)、表结构、混合检索(RRF)全部沿用,不重复造轮子。
- 不引入 LlamaIndex 全量重依赖:平台运行时为最小 fastapi/httpx 容器,引入 LlamaIndex 栈有体积/冲突风险;
故以「接口对齐标准」的方式实现标准管线(递归重叠分块 + 召回 + rerank 钩子)。后续若评估可接受,
可平滑替换为 llama_index.core 的 VectorStoreIndex + PGVectorStore(对外接口一致)。
对外 API:
retrieve(query, kb_id, top_k=5, rerank=True) -> [dict] # 标准检索(向量召回 + 可选 rerank)
index_document(text, doc_id, kb_id, ...) -> int # 标准索引(递归重叠分块后逐块入库)
"""
import logging
from app.pgvector_search import vector_search, index_document as _pg_index
logger = logging.getLogger("aiflow.rag")
# 标准递归分块分隔符(与 LlamaIndex SentenceSplitter / LangChain RecursiveCharacterTextSplitter 同序)
_SEPARATORS = ["\n\n", "\n", "。", "!", "?", ";", ".", "!", "?", ";", " ", ""]
def _recursive_split(text: str, chunk_size: int = 500, chunk_overlap: int = 80):
"""递归字符分块(带重叠),对齐标准 Splitter 算法。
逐层用分隔符切分;某段仍超长时递归细切;最终兜底硬切(带重叠步长)。
"""
text = text or ""
if len(text) <= chunk_size:
return [text] if text.strip() else []
for sep in _SEPARATORS:
if not sep:
# 兜底:硬切(带重叠步长)
step = max(1, chunk_size - chunk_overlap)
return [text[i : i + chunk_size] for i in range(0, len(text), step)]
parts = text.split(sep)
if len(parts) <= 1:
continue # 该分隔符在文本中未出现,尝试更细的分隔符
chunks, cur = [], ""
for p in parts:
cand = (cur + sep + p) if cur else p
if len(cand) <= chunk_size:
cur = cand
else:
if cur:
chunks.append(cur)
cur = ""
if len(p) > chunk_size:
chunks.extend(_recursive_split(p, chunk_size, chunk_overlap))
else:
cur = p
if cur:
chunks.append(cur)
if chunks:
return [c for c in chunks if c.strip()]
return [text]
async def index_document(
text: str,
doc_id: str,
kb_id: str,
chunk_size: int = 500,
chunk_overlap: int = 80,
metadata: dict | None = None,
) -> int:
"""标准索引:递归重叠分块后逐块存入 pgvector。返回成功分块数。"""
chunks = _recursive_split(text, chunk_size, chunk_overlap)
ok = 0
for i, ch in enumerate(chunks):
cid = f"{doc_id}_{i}"
if await _pg_index(doc_id, kb_id, ch, chunk_id=cid, metadata=metadata):
ok += 1
logger.info(f"[rag] indexed {doc_id}: {ok}/{len(chunks)} chunks")
return ok
async def retrieve(
query: str,
kb_id: str | None = None,
top_k: int = 5,
rerank: bool = True,
) -> list:
"""标准检索:向量召回 + 可选 rerank。
返回 [{doc_id, chunk_id, content, score, metadata}, ...]。
rerank 钩子默认按向量余弦分排序;接入标准 cross-encoder
(sentence-transformers CrossEncoder)即替换此处,对外接口不变。
"""
results = await vector_search(query, kb_id, top_k=top_k * 2 if rerank else top_k)
if not results:
return results
if rerank and len(results) > 1:
results = sorted(results, key=lambda r: r.get("score", 0), reverse=True)
return results[:top_k]
+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 []
+78 -107
View File
@@ -1,122 +1,96 @@
""" """
MCP (Model Context Protocol) 客户端 —— 异步实现 streamable_http 传输。 MCP (Model Context Protocol) 客户端 —— 基于官方 \"mcp\" Python SDK (Phase 1.E 标准化)
支持: 设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §E):
- initialize / notifications/initialized / tools/list / tools/call 标准流程 - 采用 Anthropic 官方 \"mcp\" SDK 处理 JSON-RPC 帧 + 协议版本协商,
- streamable_http:POST JSON-RPC,响应可能是 SSE(event: message / data: {...})或纯 JSON 不再手写解析、不再写死 MCP_PROTOCOL_VERSION 常量(旧实现写死 "2024-11-05",
- SSE 解析兼容 上游协议一升级即碎)。
- 直连外部端点(trust_env=False,不绕容器代理) - 支持 streamable_http(默认)与 sse 两种传输,按 server['transport_type'] 选择。
- 公共 API 保持向后兼容:list_mcp_tools(server, timeout) / call_mcp_tool(server, tool_name, arguments, timeout)
供 chat.py 在 AI 对话中把 MCP 工具暴露为 function calling,并代理执行 tools/call。 供 chat.py / chat_service.py / chat_agents.py / chat_skill.py / chat_mcp.py /
chat_context.py / chat_knowledge.py / chat_media.py / chat_utils.py / core/aca.py
共 10+ 处调用,签名与返回结构(tools 数组 / 文本结果字符串)不变。
- 工具列表缓存(按 url)与错误处理保持原行为,避免重复握手、保证降级安全。
""" """
import json
import logging
import asyncio import asyncio
import logging
from typing import Optional from typing import Optional
import httpx from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2024-11-05"
# 工具列表缓存(url -> (timestamp, tools)),避免同一请求内 / 短时间重复握手 # 工具列表缓存(url -> (timestamp, tools)),避免同一请求内 / 短时间重复握手
_TOOLS_CACHE: dict = {} _TOOLS_CACHE: dict = {}
_TOOLS_CACHE_TTL = 120 # 秒 _TOOLS_CACHE_TTL = 120 # 秒
def _parse_sse(text: str): def _transport_cm(server: dict, timeout: float):
"""从响应文本解析 JSON-RPC 结果。兼容 SSE(data: 行)与纯 JSON。""" """按 server['transport_type'] 选择官方 SDK 传输层。
if not text:
return None 返回异步上下文管理器,进入后产出 (read_stream, write_stream, ...)。
text = text.strip() """
# SSE 格式:多行 "event: message" / "data: {...}" url = (server.get("url") or "").strip()
data_lines = [] ttype = (server.get("transport_type") or "streamable_http").lower()
for line in text.splitlines(): headers = server.get("headers") or {}
ls = line.strip() if ttype == "sse":
if ls.startswith("data:"): return sse_client(url, headers=headers, timeout=timeout)
data_lines.append(ls[5:].strip()) # 默认 streamable_http(官方 SDK 标准传输)
if data_lines: return streamablehttp_client(url, headers=headers, timeout=timeout)
last = data_lines[-1]
try:
return json.loads(last)
except Exception:
pass
# 纯 JSON
try:
return json.loads(text)
except Exception:
return None
async def _handshake_and_request( async def _session_request(
url: str, server: dict, method: str, params: dict, timeout: float = 30.0
headers: dict,
method: str,
params: dict,
timeout: float = 30.0,
): ):
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
返回解析后的 dict:{"result": {...}} 或 None(失败)。
协议版本由 SDK 自动协商,不再硬编码。
""" """
完成一次完整 MCP 会话(initialize -> initialized -> 目标请求)。 url = (server.get("url") or "").strip()
返回解析后的 JSON-RPC 响应 dict,或 None。 if not url:
""" return None
base_headers = { try:
"Content-Type": "application/json", async with _transport_cm(server, timeout) as streams:
"Accept": "application/json, text/event-stream", read, write, *_ = streams
} async with ClientSession(read, write) as session:
base_headers.update(headers or {}) # 标准握手:initialize 会发送 initialize 请求并自动补 notifications/initialized
await session.initialize()
# 防御性补发 initialized 通知(部分服务端要求显式接收)
try:
if hasattr(session, "send_notification"):
await session.send_notification("notifications/initialized", {})
except Exception:
pass
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: if method == "tools/list":
# 1) initialize result = await session.list_tools()
r = await client.post( tools = [t.model_dump() for t in (result.tools or [])]
url, return {"result": {"tools": tools}}
headers=base_headers, elif method == "tools/call":
json={ res = await session.call_tool(
"jsonrpc": "2.0", params.get("name"), params.get("arguments") or {}
"id": 1, )
"method": "initialize", content = []
"params": { for blk in (res.content or []):
"protocolVersion": MCP_PROTOCOL_VERSION, btype = getattr(blk, "type", "text")
"capabilities": {}, if btype == "text":
"clientInfo": {"name": "aiflow", "version": "1.0"}, content.append({"type": "text", "text": getattr(blk, "text", "")})
}, else:
}, content.append({"type": btype, "text": str(blk)})
) return {
if r.status_code >= 400: "result": {
logger.warning(f"[MCP] initialize 失败 {url} -> HTTP {r.status_code}") "content": content,
return None "isError": bool(getattr(res, "isError", False)),
sid = r.headers.get("mcp-session-id") or r.headers.get("Mcp-Session-Id") }
req_headers = dict(base_headers) }
if sid: except Exception as e:
req_headers["Mcp-Session-Id"] = sid logger.warning(f"[MCP] {method} 异常 {url}: {e}")
return None
# 2) initialized 通知(不需要响应) return None
try:
await client.post(
url,
headers=req_headers,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
except Exception:
pass
# 3) 目标请求
rid = 2 if method != "tools/call" else 3
r2 = await client.post(
url,
headers=req_headers,
json={
"jsonrpc": "2.0",
"id": rid,
"method": method,
"params": params,
},
)
if r2.status_code >= 400:
logger.warning(f"[MCP] {method} 失败 {url} -> HTTP {r2.status_code}")
return None
return _parse_sse(r2.text)
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list: async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
@@ -134,9 +108,7 @@ async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL: if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1] return cached[1]
try: try:
data = await _handshake_and_request( data = await _session_request(server, "tools/list", {}, timeout)
url, server.get("headers") or {}, "tools/list", {}, timeout
)
tools = [] tools = []
if data and "result" in data: if data and "result" in data:
tools = data["result"].get("tools", []) or [] tools = data["result"].get("tools", []) or []
@@ -158,9 +130,8 @@ async def call_mcp_tool(
return f"[MCP 工具 {tool_name} 执行失败:服务端地址为空]" return f"[MCP 工具 {tool_name} 执行失败:服务端地址为空]"
logger.info(f"[MCP] 执行工具调用 tool={tool_name} server={server.get('name')} url={url}") logger.info(f"[MCP] 执行工具调用 tool={tool_name} server={server.get('name')} url={url}")
try: try:
data = await _handshake_and_request( data = await _session_request(
url, server,
server.get("headers") or {},
"tools/call", "tools/call",
{"name": tool_name, "arguments": arguments or {}}, {"name": tool_name, "arguments": arguments or {}},
timeout, timeout,
@@ -169,7 +140,7 @@ async def call_mcp_tool(
return f"[MCP 工具 {tool_name} 调用失败:无响应]" return f"[MCP 工具 {tool_name} 调用失败:无响应]"
if "error" in data: if "error" in data:
err = data["error"] err = data["error"]
return f"[MCP 工具 {tool_name} 返回错误] {json.dumps(err, ensure_ascii=False)}" return f"[MCP 工具 {tool_name} 返回错误] {__import__('json').dumps(err, ensure_ascii=False)}"
result = data.get("result", {}) result = data.get("result", {})
content = result.get("content", []) or [] content = result.get("content", []) or []
parts = [] parts = []
@@ -177,7 +148,7 @@ async def call_mcp_tool(
if isinstance(item, dict) and item.get("type") == "text": if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", "")) parts.append(item.get("text", ""))
else: else:
parts.append(json.dumps(item, ensure_ascii=False)) parts.append(__import__("json").dumps(item, ensure_ascii=False))
text = "\n".join(parts).strip() text = "\n".join(parts).strip()
if result.get("isError"): if result.get("isError"):
return f"[MCP 工具 {tool_name} 执行报错]\n{text}" return f"[MCP 工具 {tool_name} 执行报错]\n{text}"
+51 -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 import Text # PG-compatible (was MEDIUMTEXT from mysql dialect)
from .database import Base from .database import Base
@@ -31,7 +32,7 @@ class User(Base):
phone = Column(String(20), default=None) phone = Column(String(20), default=None)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
avatar = Column(String(500), default=None) avatar = Column(String(500), default=None)
role = Column(String(10), nullable=False, default="user") # user / admin role = Column(String(20), nullable=False, default="user") # super_admin/admin/developer/user/guest
status = Column(String(10), nullable=False, default="active") # active / banned status = Column(String(10), nullable=False, default="active") # active / banned
real_name_status = Column(String(20), nullable=False, default="unverified") real_name_status = Column(String(20), nullable=False, default="unverified")
real_name = Column(String(50), default=None) real_name = Column(String(50), default=None)
@@ -39,6 +40,7 @@ class User(Base):
membership_plan_id = Column(String(36), default=None) membership_plan_id = Column(String(36), default=None)
membership_level = Column(Integer, default=0) membership_level = Column(Integer, default=0)
membership_expire_at = Column(DateTime, default=None) membership_expire_at = Column(DateTime, default=None)
tenant_id = Column(String(36), default=None)
auto_renew = Column(Boolean, default=False) auto_renew = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -75,6 +77,47 @@ class ApiKey(Base):
# AI聊天与频道 # AI聊天与频道
# ============================================================ # ============================================================
class UserGroup(Base):
__tablename__ = "user_groups"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(500), default=None)
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
tenant_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
owner = relationship("User", foreign_keys=[owner_id])
class UserGroupMember(Base):
__tablename__ = "user_group_members"
id = Column(String(36), primary_key=True, default=gen_uuid)
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime, default=datetime.now)
group = relationship("UserGroup")
user = relationship("User")
class GroupModelQuota(Base):
__tablename__ = "group_model_quotas"
id = Column(String(36), primary_key=True, default=gen_uuid)
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
model_id = Column(String(36), ForeignKey("models.id"), nullable=False)
daily_limit = Column(Integer, default=1000)
rpm_limit = Column(Integer, default=60)
group = relationship("UserGroup")
model = relationship("AIModel")
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
slug = Column(String(50), unique=True, nullable=False)
status = Column(String(20), default="active")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Conversation(Base): class Conversation(Base):
__tablename__ = "conversations" __tablename__ = "conversations"
@@ -286,6 +329,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)
@@ -525,6 +572,7 @@ class ModelProvider(Base):
protocol = Column(String(30), nullable=False, default="openai_compatible") protocol = Column(String(30), nullable=False, default="openai_compatible")
api_key = Column(String(255), default=None) api_key = Column(String(255), default=None)
api_base_url = Column(String(500), default=None) api_base_url = Column(String(500), default=None)
auth_header = Column(String(100), default="Authorization: Bearer", comment="自定义认证头,如MiMo用api-key")
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -945,7 +993,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 +1204,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)
+40 -21
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,27 +131,20 @@ 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 # 收敛到共享标准模型层(app.core.llm_client.aembed),与聊天共用代理/超时/failover 语义
async with httpx.AsyncClient(timeout=30) as client: from app.core.llm_client import aembed
resp = await client.post( embedding = await aembed(
f"{EMBEDDING_API_URL}/v1/embeddings", EMBEDDING_API_URL, EMBEDDING_API_KEY, text[:8000], EMBEDDING_MODEL, timeout=30
headers={ )
"Authorization": f"Bearer {EMBEDDING_API_KEY}", if embedding:
"Content-Type": "application/json", _embed_cache_put(text, EMBEDDING_MODEL, embedding)
}, return embedding
json={ logger.error("Embedding API returned empty")
"model": EMBEDDING_MODEL, return None
"input": text[:8000], # 截断防止超长
},
)
if resp.status_code == 200:
data = resp.json()
embedding = data["data"][0]["embedding"]
return embedding
else:
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
return None
except Exception as e: except Exception as e:
logger.error(f"Embedding generation failed: {e}") logger.error(f"Embedding generation failed: {e}")
return None return None
+77 -5
View File
@@ -38,7 +38,7 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
## 5. IM Bot(微信集成) ## 5. IM Bot(微信集成)
- **个人微信Bot**: 通过iLink官方接口接入,扫码绑定,微信内直接AI对话 - **个人微信Bot**: 通过iLink官方接口接入,扫码绑定,微信内直接AI对话
- **企业微信/飞书/QQ/悟空IM**: Webhook框架已就绪,支持自定义机器人 - **企业微信/飞书/QQ/悟空IM**: Webhook框架已绪,支持自定义机器人
- **统一会话管理**: 微信消息并入平台AI聊天,统一历史管理 - **统一会话管理**: 微信消息并入平台AI聊天,统一历史管理
## 6. 资源中心 ## 6. 资源中心
@@ -73,7 +73,7 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
- **安全**: JWT鉴权、HTTPS加密、操作审计日志 - **安全**: JWT鉴权、HTTPS加密、操作审计日志
## AI回复规范 ## AI回复规范
1. **回复格式**: 使用Markdown(标题 `##`/`###`、加粗 `**`、列表 `-`/`1.`、代码块等) 1. **回复格式**: 使用Markdown(标题 \"##\"/\"###\"、加粗 \"**\"、列表 \"-\"/\"1.\"、代码块等)
2. **层次分明**: 使用标题分隔段落,确保信息有层次 2. **层次分明**: 使用标题分隔段落,确保信息有层次
3. **平台优先**: 回答问题时优先引用本平台功能,而非泛泛建议 3. **平台优先**: 回答问题时优先引用本平台功能,而非泛泛建议
4. **准确具体**: 基于平台文档给出具体操作路径、配置方法、API示例 4. **准确具体**: 基于平台文档给出具体操作路径、配置方法、API示例
@@ -81,6 +81,78 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
""" """
def get_platform_knowledge() -> str: def get_platform_knowledge(db=None, user_id=None) -> str:
"""获取平台全功能知识库文本,注入到AI对话系统提示中""" """获取平台全功能知识库文本,注入到AI对话系统提示中。
return PLATFORM_KNOWLEDGE
若传入 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.type, MarketApp.name)
.all()
)
if not apps:
return PLATFORM_KNOWLEDGE
# 按类型分组统计
type_count = {}
for a in apps:
t = getattr(a, "type", None) or "skill"
type_count[t] = type_count.get(t, 0) + 1
type_brief = ", ".join([f"{({'skill':'技能','agent':'Agent','mcp':'MCP应用','mcp_tool':'MCP工具'}.get(k,k))} {v}" for k, v in sorted(type_count.items())])
# 按能力(capability)分组
from collections import OrderedDict
by_cap = OrderedDict()
for a in apps:
cap = getattr(a, "capability", None) or getattr(a, "category", None) or "通用"
by_cap.setdefault(cap, []).append(a)
# 强制完整性说明(关键变更)
lines = [
"# 📦 平台已上线能力清单(实时同步自数据库,可放心信任此清单)",
"",
f"**总数:{len(apps)} 个已上线能力**({type_brief})",
"",
"## ⚠️ 强制完整性指令",
"当用户询问以下任何一类问题时,**必须输出下方全部内容**:",
'- "平台有哪些技能/应用/MCP"',
'- "全部技能" / "列一下技能" / "技能清单"',
'- "有什么 Skills" / "支持哪些 Skills"',
'- "列举所有" / "每个都列出来"',
"",
"**绝对不允许**精简、抽样、挑代表性的几个、或改写为综述类叙述。**逐条输出下方每一项**,可以按类别分组展示,但不可省略任何一条。",
"",
]
# 按能力分组渲染
type_emoji = {"skill": "🛠", "agent": "🤖", "mcp": "🔌", "mcp_tool": "🔧"}
for cap in by_cap.keys():
cap_apps = by_cap[cap]
lines.append(f"## 【{cap}】- {len(cap_apps)} 个")
for a in cap_apps:
name = getattr(a, "name", None) or "未命名"
desc = (getattr(a, "description", "") or "").strip().replace("\n", " ")
# 描述长度提升到 100 字,给出更可识别的简介
if len(desc) > 100:
desc = desc[:100] + "…"
emoji = type_emoji.get(getattr(a, "type", "skill"), "📦")
lines.append(f"- {emoji} **{name}**: {desc}")
lines.append("")
dynamic_section = "\n".join(lines)
# 关键:动态能力清单放在最前面,让 AI 优先处理
return dynamic_section + "\n\n---\n\n# 平台静态文档\n\n" + PLATFORM_KNOWLEDGE
except Exception as e:
logger.warning(f"[platform_knowledge] 动态构建失败,回退静态知识库: {e}")
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}")
traj.error_msg = str(e)[:500] # 先回滚失效事务(_process_trajectory 内 commit 失败会让 session
traj.retry_count = (traj.retry_count or 0) + 1 # 进入 rolled-back 状态,不回滚直接再 commit 会抛 PendingRollbackError
traj.status = "error" # 并级联到外层日志),再写错误状态。
db.commit() try:
db.rollback()
except Exception:
pass
try:
traj.error_msg = str(e)[:500]
traj.retry_count = (traj.retry_count or 0) + 1
traj.status = "error"
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
+39
View File
@@ -11,6 +11,30 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
# Phase0: Tenant RLS Middleware
class TenantRLSMiddleware(BaseHTTPMiddleware):
"""Inject tenant_id from JWT into PostgreSQL for RLS policies"""
async def dispatch(self, request: Request, call_next):
try:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
from jose import jwt
from app.auth import SECRET_KEY, ALGORITHM
token = auth_header[7:]
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
tenant_id = payload.get("tenant_id")
if tenant_id:
from app.database import SessionLocal
db = SessionLocal()
try:
db.execute(f"SET app.current_tenant = '{tenant_id}'")
finally:
db.close()
except Exception:
pass
return await call_next(request)
from datetime import datetime from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -118,6 +142,18 @@ def _record_api_call(db, method: str, path: str, status_code: int, elapsed: floa
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...") logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 幂等确保 conversation_summaries.structured_memory 列。
# 原实现在每次聊天的请求路径中同步执行 engine.connect().execute(SELECT information_schema.columns),
# 该同步阻塞调用运行在 asyncio 事件循环线程上,会导致整个事件循环冻结(所有端点挂死,
# 表现为「单 worker 永久阻塞、docker restart 才恢复」)。改为启动时一次性执行,
# 既保留对全新库的兼容,又绝不阻塞任何用户请求。
try:
from app.api.chat_context import ensure_structured_column
ensure_structured_column()
logger.info("[startup] ensure_structured_column 完成")
except Exception as _e:
logger.warning(f"[startup] ensure_structured_column 失败(可忽略): {_e}")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try: try:
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager
@@ -194,6 +230,9 @@ app.add_middleware(
) )
# 前台 API # 前台 API
# Phase0: Register RLS middleware
app.add_middleware(TenantRLSMiddleware)
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
+1
View File
@@ -28,3 +28,4 @@ click==8.4.2
click-didyoumean==0.3.1 click-didyoumean==0.3.1
click-plugins==1.1.1.2 click-plugins==1.1.1.2
click-repl==0.3.0 click-repl==0.3.0
psycopg[binary]
+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}"
)
@@ -0,0 +1,35 @@
version: "1.0"
name: "Code Review Pipeline"
description: "Generate code → review → merge. A standard code-review workflow."
category: development
nodes:
- id: code_gen
type: llm
description: "Generate code based on requirements"
on_error: halt
config:
model: auto
system_prompt: "You are a senior software engineer. Write clean, production-ready code based on the user's requirements. Include comments and error handling."
- id: review
type: llm
description: "Review generated code for bugs and style"
on_error: continue
config:
model: auto
system_prompt: "You are a code reviewer. Analyze the provided code for bugs, style issues, performance problems, and security vulnerabilities. Provide specific, actionable feedback."
- id: merge
type: reduce
description: "Combine code with review feedback"
config:
strategy: concat
edges:
- from: code_gen
to: review
- from: code_gen
to: merge
- from: review
to: merge
@@ -0,0 +1,35 @@
version: "1.0"
name: "Multi-Agent Debate"
description: "Two agents debate a topic from opposing perspectives, then a judge agent synthesizes the conclusion."
category: creativity
nodes:
- id: proponent
type: llm
description: "Argue in favor of the proposition"
on_error: continue
config:
model: auto
system_prompt: "You are a skilled debater. Argue strongly IN FAVOR of the given proposition. Use logic, evidence, and persuasive reasoning. Be passionate but fair."
- id: opponent
type: llm
description: "Argue against the proposition"
on_error: continue
config:
model: auto
system_prompt: "You are a skilled debater. Argue strongly AGAINST the given proposition. Use logic, evidence, and persuasive reasoning to find flaws in the proponent's case."
- id: judge
type: llm
description: "Evaluate both sides and reach a conclusion"
on_error: continue
config:
model: auto
system_prompt: "You are an impartial judge. Evaluate both arguments fairly. Identify the strongest points from each side. Provide a balanced conclusion and recommendation."
edges:
- from: proponent
to: judge
- from: opponent
to: judge
@@ -0,0 +1,25 @@
version: "1.0"
name: "RAG Search Pipeline"
description: "Search knowledge base → augment with context → generate answer. Standard RAG pipeline."
category: productivity
nodes:
- id: search
type: llm
description: "Generate search queries and retrieve relevant context"
on_error: continue
config:
model: auto
system_prompt: "You are a search specialist. For the given question, identify 2-3 key search points. Then search the provided knowledge base for relevant information. Return the most relevant passages with source references."
- id: answer
type: llm
description: "Generate final answer with citations"
on_error: continue
config:
model: auto
system_prompt: "You are a knowledge assistant. Using the search results provided, generate a comprehensive answer. Always cite your sources. If information is insufficient, clearly state what is missing."
edges:
- from: search
to: answer
@@ -0,0 +1,25 @@
version: "1.0"
name: "Research & Answer"
description: "Research topic → generate answer. Uses two LLM nodes for research and synthesis."
category: productivity
nodes:
- id: research
type: llm
description: "Search and gather information on the topic"
on_error: continue
config:
model: auto
system_prompt: "You are a thorough researcher. Search for key facts, data points, and relevant context about the given topic. Provide a comprehensive summary with citations."
- id: answer
type: llm
description: "Synthesize research into final answer"
on_error: continue
config:
model: auto
system_prompt: "You are a professional writer. Based on the research summary provided, craft a clear, well-structured answer for the user. Include key findings and actionable insights."
edges:
- from: research
to: answer
+8 -35
View File
@@ -21,11 +21,13 @@ services:
- "12888:8080" - "12888:8080"
- "18080:18080" - "18080:18080"
environment: environment:
- MYSQL_HOST=mysql - DATABASE_URL=postgresql+psycopg://aiflow:pwd2026@qihu-aiflow-pg18:5432/aiflow
- MYSQL_PORT=3306 - POSTGRES_HOST=qihu-aiflow-pg18
- MYSQL_USER=root - POSTGRES_PORT=5432
- MYSQL_PASSWORD=pwd2026 - POSTGRES_USER=aiflow
- MYSQL_DATABASE=aiflow - POSTGRES_PASSWORD=pwd2026
- POSTGRES_DB=aiflow_vectors
- PG_HOST=qihu-aiflow-pg18
- REDIS_HOST=redis - REDIS_HOST=redis
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=pwd2026 - REDIS_PASSWORD=pwd2026
@@ -46,10 +48,8 @@ services:
- DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash - DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash
- HTTP_PROXY=http://172.18.0.1:7890 - HTTP_PROXY=http://172.18.0.1:7890
- HTTPS_PROXY=http://172.18.0.1:7890 - HTTPS_PROXY=http://172.18.0.1:7890
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com - NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
depends_on: depends_on:
mysql:
condition: service_healthy
redis: redis:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
@@ -63,32 +63,6 @@ services:
max-size: "50m" max-size: "50m"
max-file: "10" max-file: "10"
mysql:
image: mysql:8.0
container_name: qihu-aiflow-mysql
command: --ssl=0
environment:
- MYSQL_ROOT_PASSWORD=pwd2026
- MYSQL_DATABASE=aiflow
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
restart: unless-stopped
networks:
- aiflow-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ppwd2026"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: qihu-aiflow-redis container_name: qihu-aiflow-redis
@@ -107,7 +81,6 @@ services:
max-file: "5" max-file: "5"
volumes: volumes:
mysql_data:
redis_data: redis_data:
networks: networks:
+544
View File
@@ -0,0 +1,544 @@
# 七虎AI PaaS 平台 — 架构设计与实施方案 v2.0
> **最后更新**: 2026-07-27
> **版本**: v2.0
> **用途**: 投喂 AI 大模型进行平台竞争力、护城河、扩展能力分析
> **维护**: 随系统迭代实时更新
---
## 一、平台概述
### 1.1 定位
七虎AI(qihuyun.com)是一个面向开发者和企业的 **AI PaaS 平台**,核心能力是聚合多厂商大模型、提供标准化工具调用层、支持 Skill/MCP/Agent 的发现-安装-编排-执行全生命周期管理。
### 1.2 核心价值主张
| 价值维度 | 实现方式 |
|---------|---------|
| 模型聚合 | 统一 OpenAI 兼容协议接入 4+ 厂商(SiliconFlow、MiMo、AGNES、DeepSeek),自动 failover |
| 技能市场 | Skills 的上架-搜索-安装-执行闭环,支持 14 个能力分类 |
| MCP 协议 | 官方 Anthropic MCP SDK 实现,支持 streamable_http/SSE 传输 |
| Agent 编排 | DAG 图引擎 + Supervisor 模式,支持外部 Agent(Dify/n8n/Coze)远程调用 |
| IM Bot | 多平台(微信/企业微信/飞书/QQ)统一消息网关 |
| 知识库 RAG | PostgreSQL pgvector + 混合检索 |
| 媒体生成 | 异步图片/视频生成(后台线程,不阻塞 SSE) |
| 可观测 | Prometheus+Grafana 监控栈 + 日志审计 |
---
## 二、整体架构
### 2.1 部署架构
```
Internet
│
┌────▼────┐
│ Nginx │ :12888 (HTTPS)
└────┬────┘
│
┌──────────────┼──────────────┐
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼─────┐
│ React │ │FastAPI │ │ Casdoor │
│ Vite │ │uvicorn │ │ :8000 │
│ (dist/) │ │:18080 │ └─────────┘
└─────────┘ └───┬────┘
│
┌─────────────┼─────────────┐
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼──────┐
│Postgres │ │ Redis │ │ Executor │
│PG18+ │ │:6379 │ │ (Code) │
│pgvector │ └────────┘ └──────────┘
└─────────┘
```
### 2.2 容器清单
| 容器 | 镜像 | 用途 |
|------|------|------|
| qihu-aiflow | 自建(custom) | 主应用(Nginx + FastAPI uvicorn + React dist) |
| qihu-aiflow-pg18 | pgvector/pgvector:pg16 | PostgreSQL 主库 + 向量扩展 |
| qihu-aiflow-redis | redis:7-alpine | 缓存/队列/会话 |
| qihu-aiflow-casdoor | casdoor | 统一认证 |
| qihuaiflow-executor | 自建 | 代码执行沙箱 |
| qihu-aiflow-prometheus | prom/prometheus | 指标采集 |
| qihu-aiflow-grafana | grafana/grafana | 可视化监控 |
| qihu-aiflow-alloy | grafana/alloy | 日志采集 Agent |
| qihu-aiflow-node-exporter | prom/node-exporter | 宿主机指标 |
### 2.3 数据流
```
用户请求 → Nginx(:12888) → uvicorn(:18080)
├── /app/api/chat/* → chat.py (SSE streaming)
├── /app/api/auth/* → auth.py (JWT)
├── /app/api/admin/* → admin/* (CRUD + 管理)
└── / → index.html → React SPA
```
---
## 三、技术栈矩阵
| 层级 | 技术 | 版本 | 选型理由 |
|------|------|------|---------|
| 后端框架 | FastAPI (Python) | 0.115+ | 异步原生、SSE 流式支持、Pydantic 验证 |
| ASGI 服务器 | uvicorn | 0.30+ | 单进程事件循环、生产级 |
| 前端框架 | React + Vite | 18 + 5 | 组件化、HMR 热更新、Tree-shaking |
| 前端语言 | TypeScript | 5.6+ | 类型安全 |
| 关系数据库 | PostgreSQL | 18 | JSONB、pgvector 原生支持、AIO 引擎 |
| 向量引擎 | pgvector | 0.8.4 | 与主库同集群、免运维 |
| 缓存 | Redis | 7 | 会话、队列、工具列表缓存 |
| 认证 | Casdoor | latest | 开源统一认证、OAuth2/OIDC |
| ORM | SQLAlchemy | 2.0.36 | 成熟生态、支持 async |
| LLM 客户端 | openai Python SDK | 1.x | OpenAI 兼容协议统一接入 |
| MCP 客户端 | mcp Python SDK | 1.28.1 | Anthropic 官方 SDK |
| 代理 | Nginx | 1.25+ | 反向代理、静态文件、SSL |
| 容器 | Docker Compose | v2 | 单机编排 |
| 监控 | Prometheus + Grafana | latest | 开源标准、全栈可见 |
---
## 四、数据模型(67 张表,按域分组)
### 4.1 核心业务域
| 表名 | 说明 | 关键字段 |
|------|------|---------|
| users | 用户 | id, username, password_hash, role |
| conversations | 对话 | id, user_id, title, model_id |
| messages | 消息 | id, conv_id, role, content, tool_calls, used_skills |
| models | AI 模型 | id, provider_id, name, endpoint, capabilities, is_default |
| model_providers | 模型提供商 | id, name, protocol, api_key, api_base_url, auth_header |
| site_settings | 站点配置 | key, value (JSON) |
| mcpservers | MCP 服务 | id, name, url, transport_type, is_enabled, tools_count |
### 4.2 技能市场域
| 表名 | 说明 |
|------|------|
| market_apps | 市场应用(Skills/MCP/Agents) |
| market_app_versions | 应用版本 |
| app_purchases | 购买记录 |
| app_reviews | 评价 |
| app_installations | 安装记录 |
| skill_versions | 技能版本 |
| skill_publish_records | 发布记录 |
| evolution_skills | 进��技能 |
| evolution_events | 进化事件 |
| evolution_trajectories | 进化轨迹 |
| team_shared_skills | 团队共享技能 |
### 4.3 知识库域
| 表名 | 说明 |
|------|------|
| knowledge_bases | 知识库 |
| knowledge_directories | 知识目录 |
| knowledge_documents | 文档 |
| knowledge_document_chunks | 文档分块 |
| knowledge_graph_nodes | 知识图谱节点 |
| knowledge_graph_edges | 知识图谱边 |
| external_kb_services | 外部 KB 服务 |
### 4.4 会员/支付域
| 表名 | 说明 |
|------|------|
| membership_plans | 会员计划 |
| membership_subscriptions | 订阅 |
| orders | 订单 |
| coupons | 优惠券 |
| user_coupons | 用户优惠券 |
| value_added_services | 增值服务 |
| payment_configs | 支付配置 |
### 4.5 监控/运维域
| 表名 | 说明 |
|------|------|
| token_usage_logs | Token 用量日志 |
| token_balances | 余额 |
| operation_logs | 操作日志 |
| audit_logs | 审计日志 |
| login_logs | 登录日志 |
| risk_rules | 风控规则 |
| slow_request_logs | 慢请求日志 |
| api_call_stats | API 调用统计 |
| task_execution_logs | 任务执行日志 |
---
## 五、API 全景
### 5.1 前台 API(用户面)
| 路由前缀 | 端点 | 方法 | 说明 |
|---------|------|------|------|
| /app/api/auth | /login | POST | 用户登录,返回 JWT |
| /app/api/auth | /register | POST | 注册 |
| /app/api/chat | /conversations | GET/POST | 对话列表/创建 |
| /app/api/chat | /conversations/{id}/messages | POST | 发送消息(非流式) |
| /app/api/chat | /conversations/{id}/messages/stream | POST | **SSE 流式聊天** |
| /app/api/chat | /models | GET | 可用模型列表 |
| /app/api/chat | /media/relay | GET | 媒体代理 |
| /app/api/market | /apps | GET | 应用市场列表 |
| /app/api/market | /apps/{id} | GET | 应用详情 |
| /app/api/knowledge | /search | POST | 知识库搜索 |
### 5.2 后台 API(管理面)
| 路由前缀 | 说明 |
|---------|------|
| /app/api/admin/users | 用户管理 |
| /app/api/admin/models | 模型管理 |
| /app/api/admin/skills | 技能管理 |
| /app/api/admin/agents | Agent 管理 |
| /app/api/admin/mcp | MCP 服务管理 |
| /app/api/admin/workflow | 工作流管理 |
| /app/api/admin/market | 市场管理 |
| /app/api/admin/analytics | 数据分析 |
| /app/api/admin/developer | 开发者 API |
| /app/api/admin/settings | 站点设置 |
| /app/api/admin/storage | 存储管理 |
| /app/api/admin/logs | 日志查看 |
| /app/api/admin/automation | 自动化任务 |
### 5.3 SSE 事件类型
```
POST /conversations/{id}/messages/stream
Content-Type: text/event-stream
事件帧:
data: {"type":"reasoning","content":"..."} // 模型推理过程
data: {"type":"delta","content":"..."} // 逐字回复增量
data: {"type":"step","step":{...}} // 工具调用步骤
step.type: "thinking" | "skill" | "mcp"
data: {"type":"done","message_id":"..."} // 流结束
```
---
## 六、核心管道与工作流
### 6.1 消息处理管道(主循环)
```
用户消息
→ 鉴权(JWT Bearer)
→ 上下文恢复(skill_ids/agent_ids/mcp_ids 跨轮继承)
→ 技能解析(显式选择 → 自动发现 → Agent 合并)
→ MCP 工具列表拉取(带缓存 TTL 120s,按 url + transport 缓存)
→ 知识库检索(RAG,BM25 + vector hybrid)
→ System Prompt 组装(AGENT_GUIDE + platform_knowledge + agent_role + skill_desc + kb_context)
→ Function Definitions 组装(skills → tools + MCP → tools + install_app)
→ LLM 调用(OpenAI 兼容协议,stream=True)
→ Tool Calls 循环(max 15 轮,每轮 tool_policy 沙箱检查)
├── MCP Tool → call_mcp_tool() via Anthropic SDK
├── Skill → _execute_skill_tool() 指令转发
└── install_app → 安装到市场
→ 工具结果回注 → 继续 LLM 循环 → 最终回复
→ 上下文压缩(后台异步,fire-and-forget)
→ 结构化记忆抽取(实体/事实/偏好/决策/待办 五类)
→ 媒体生成(后台 daemon 线程,独立事件循环)
```
### 6.2 模型路由与 Failover 管道
```
模型选择 → 解析 api_url/api_key/provider.auth_header
→ build_openai_client(httpx.Timeout + trust_env=False + auth_header 支持)
→ 首次调用失败(Exception)→ continue 下一个模型
→ 备用模型列表(order_by is_default desc × 5)
→ 成功 → break
→ 全部失败 → 返回错误消息
```
### 6.3 MCP 协议管道
```
Client: mcp_client.py (官方 Anthropic SDK)
list_mcp_tools(server) → tools/list (JSON-RPC)
⇢ 缓存: _TOOLS_CACHE[url] = (timestamp, tools), TTL 120s
call_mcp_tool(server, name, arguments) → tools/call (JSON-RPC)
⇢ 返回: text content blocks → 拼接为字符串 → 反馈给 LLM
传输层:
streamable_http (默认) → streamablehttp_client()
sse → sse_client()
Schema 适配:
MCP inputSchema (JSON Schema 2020-12) → _normalize_mcp_schema()
⇢ 展开 $ref → 处理 oneOf/anyOf → allOf 合并 → 剥离非 OpenAI 关���字
⇢ 转为 OpenAI function calling 兼容的 parameters
认证:
Authorization: Bearer {key} (默认)
api-key: {key} (MiMo Token Plan 等自定义)
```
### 6.4 Agent 编排管道(DAG 图引擎)
```
YAML 定义 → validate_graph() → topological_layers() (Kahn算法)
→ 按层并行执行 (asyncio.gather)
节点类型:
llm → _build_system_prompt + LLM 调用
local → _execute_skill_tool (本地技能)
external → _call_external_agent (Dify/n8n/Coze 远程)
reduce → 多路结果聚合
边: 有向边定义数据流 → 拓扑排序 → 分层并行
失败隔离: per-node on_error=halt|continue
并发安全: 每节点独立 SessionLocal()
预处理流水线:
外部 Agent endpoint → 直接远程调用(跳过本地 LLM)
Orchestrator → run_graph() → 图执行 → 聚合回答
```
### 6.5 媒体生成管道
```
SSE 回复结束 → _launch_media_task_bg()
→ daemon Thread → asyncio.run(_generate_media_task)
→ 独立事件循环(不阻塞主 uvicorn 循环)
→ HTTP 调用图片/视频生成 API
→ 写入存储 → 更新 Message 记录
→ 前端轮询 → 渲染结果
```
### 6.6 上下文压缩管道
```
(后台异步, fire-and-forget, 不阻塞用户响应)
旧消息 (>20 条保护窗口外)
→ 分块 (CHUNK=30) → _summarize_via_llm()
→ chunk_summaries 合并
→ _recursive_compress() (depth≤3, budget=700 tokens)
→ _extract_structured_memory()
实体/事实/偏好/决策/待办 五类
→ _merge_structured_memory(old, new)
→ ConversationSummary 写入 PG
```
---
## 七、前端架构
### 7.1 路由设计
```
前台 (/):
/login - 登录
/chat[/:id] - AI 对话(核心页面,SSE 流式)
/knowledge - 知识库管理
/market - 应用市场(Skills/MCP/Agents)
/token - Token 用量面板
/pricing - 定价/会员
/profile - 个人中心
/assets[/novel/:id] - 资产/小说
/evolution - 进化仪表板
/wechat-bot - 微信 Bot
/im-bots - 多平台 IM Bot Hub
/app-services - 应用服务
后台 (/admin):
/admin - 仪表板
/admin/users - 用户管理
/admin/models - 模型管理
/admin/skills - 技能管理
/admin/agents - Agent 管理
/admin/mcp - MCP 管理
/admin/workflow - 工作流
/admin/market - 市场审核
/admin/knowledge - 知识库管理
/admin/code-exec - 代码执行
/admin/storage - 存储管理
/admin/developer[/tools] - 开发者 API/工具
/admin/automation - 自动化任务
/admin/settings - 站点设置
/admin/analytics - 数据分析
/admin/logs - 日志
/admin/membership - 会员管理
/admin/payment - 支付配置
/admin/services - 增值服务
/admin/ads - 广告管理
/admin/apps - 应用管理
```
### 7.2 核心组件
| 组件 | 文件 | 功能 |
|------|------|------|
| ChatPage | front/ChatPage.tsx | SSE 流式聊天、Step 渲染、推理展示 |
| AiProcessBlock | ChatPage.tsx 内 | 工具调用过程可视化(⏳/✅/❌) |
| MarketPage | front/MarketPage.tsx | 应用市场网格、分类筛选 |
| FrontLayout | components/layout/FrontLayout.tsx | 前台 sidebar + main 布局 |
| AdminLayout | components/layout/AdminLayout.tsx | 后台 sidebar + main 布局 |
### 7.3 设计系统
```
CSS 变量(global.css :root):
--color-accent: #5B5FED
--color-bg-primary: #F8F9FB
--sidebar-width: 240px
--space-xs..4xl: 4px..40px
--radius-md..3xl: 8px..20px
防溢出: html/body/.front-main { overflow-x: hidden }
响应式: @media (max-width: 1200px/768px) 断点
```
---
## 八、安全设计
### 8.1 认证
- JWT Bearer Token 认证(FastAPI HTTPBearer)
- Casdoor OAuth2/OIDC 统一认证(可选)
- Token 前端存内存,401/403 清 token 跳 /login
### 8.2 授权
- 前台: 用户级(需登录)
- 后台: 管理员级(role=admin)
- API Key: 支持 per-model key 覆盖 provider key
### 8.3 工具调用沙箱
```
_check_tool_policy() 三层防护:
1. 白名单: agent 只允许调特定 skill_ids + mcp_ids
2. 预算: max_calls_per_conversation 上限
3. 每轮上限: per_round_cap
代码执行: 独立 executor 容器沙箱
密钥管理: credential.py 加解密存储
```
---
## 九、运维架构
### 9.1 CI/CD
```
Git push → pre-push hook (.git/hooks/pre-push)
→ 容器内 backend pytest (28 用例)
→ 容器内 frontend vitest (2 文件, 15 用例)
→ 全绿 → push 放行
→ 失败 → block
部署:
docker cp 容器文件 → 宿主 Git
→ git add -A + commit
→ docker commit → qihuaiflow:with-deps (新镜像)
→ docker compose up -d --force-recreate
```
### 9.2 健康检查
```
/health → 200 {status: "ok"} (liveness)
前端 :8080 → 200 HTML (SPA)
后端 :18080/docs → 200 Swagger UI
容器: docker restart → < 20s 恢复
```
### 9.3 监控栈
```
Prometheus (scrape) ← node-exporter (host) + Alloy (logs)
Grafana (visualize) ← Prometheus datasource
应用日志: Python logging → docker logs / Alloy 采集
```
---
## 十、市场竞争分析
### 10.1 护城河
| 护城河 | 具体实现 | 壁垒强度 |
|--------|---------|---------|
| **多模型协议适配层** | OpenAI/Anthropic/Token Plan 三种认证头、MCP Schema → OpenAI 转换 | 🔴 高 |
| **Skill 全生命周期** | 发布→审核→上架→安装→执行→进化,闭环 | 🔴 高 |
| **Agent DAG 编排** | 拓扑排序+分层并行+失败隔离+外部 Agent 远程调用 | 🟡 中 |
| **工具调��标准化** | AGENT_GUIDE + tool_policy 沙箱 + 15 轮 loop + 错误自修正 | 🟡 中 |
| **PG 统一数据层** | 主库+向量+Casdoor 同集群 pgvector,混合检索零延迟 | 🟡 中 |
| **IM Bot 多平台** | 统一消息网关,微信/企业微信/飞书/QQ | 🟢 低 |
| **安全沙箱** | Tool Policy 白名单+预算+代码执行容器隔离 | 🟡 中 |
### 10.2 竞品对比
| 维度 | 七虎AI | Dify | Coze | 扣子 | FastGPT |
|------|--------|------|------|------|---------|
| 模型聚合 | ✅ 手动 failover | ✅ | ✅ | ✅ | ✅ |
| Skill 市场 | ✅ 上架-安装-进化 | ❌ | ❌ (Plugin) | ❌ (Plugin) | ❌ |
| MCP 协议 | ✅ 官方 SDK | ✅ | ❌ | ❌ | ❌ |
| Agent DAG | ✅ 图引擎 | ✅ 工作流 | ✅ 工作流 | ✅ 工作流 | ❌ 简单 |
| IM Bot | ✅ 多平台 | ❌ | ❌ | ✅ 飞书 | ❌ |
| PG 全栈 | ✅ PG18 | ⚠️ MySQL | ? | ? | ✅ PG |
| Auth | ✅ Casdoor+JWT | ✅ | ✅ | ✅ | ✅ |
### 10.3 扩展能力
| 扩展方向 | 当前能力 | 增长空间 |
|---------|---------|---------|
| 模型厂商 | 4 家 | 新增厂商只需配 ModelProvider |
| Skill 类型 | 14 场景分类 | 支持自定义执行器(非纯 LLM 转发) |
| MCP 服务 | 任意 MCP Server | MCP Registry 对接 |
| Agent | Dify/n8n/Coze + 自研 DAG | CrewAI/LangGraph 集成 |
| 前端渠道 | Web Chat + IM Bot | API/Webhook/SDK |
| 计费 | Token + 会员 | 按 Skill/MCP 调用计费 |
---
## 十一、迭代路线图
### 已完成
- [x] PostgreSQL 迁移(MySQL → PG18 + pgvector)
- [x] SSE 四类事件(reasoning/delta/step/done)
- [x] MCP 协议标准化(官方 SDK,Schema 转换)
- [x] Agent DAG 编排引擎 + YAML workflow
- [x] 工具调用标准化(AGENT_GUIDE + install_app 优化 + max_rounds 15 + MCP 命名)
- [x] 模型 Failover + 事件循环防冻(#812)
- [x] Token Plan 适配(MiMo custom auth_header)
- [x] 前端 Step 渲染增强(⏳/✅/❌ 状态)
- [x] 横向溢出防护(overflow-x: hidden 三层)
- [x] 知识库 KG 构建
- [x] 多平台 IM Bot Hub
- [x] Prometheus+Grafana 监控栈
### 进行中
- [ ] Skill 真实执行器(非 LLM 转发模拟)
- [ ] 多 Agent 协作(Supervisor 模式)
- [ ] API/OpenAPI 对外接口
- [ ] 技能 SDK 发布工具
### 计划中
- [ ] LiteLLM 集成(统一模型路由+成本追踪)
- [ ] 流式 Token 计费(精确到字符级)
- [ ] LangGraph 工作流引擎
- [ ] 向量混合检索增强(BM25 + dense + sparse)
- [ ] 前端暗色模式
- [ ] 多语言(i18n)
- [ ] 移动端适配
---
> **文档维护规则**: 每次系统重大迭代后,更新本文档对应章节并变更版本号。
> **上传位置**: Gitea 仓库 `qihuaiflow/docs/ARCHITECTURE.md`
+286
View File
@@ -0,0 +1,286 @@
# 七虎AI PaaS 平台 — 迭代优化可执行方案 v1.0
> **来源**: 基于 `AI_PaaS_Platform_Analysis_and_Execution_Plan v2.md`(元宝深度分析文档)
> **日期**: 2026-07-27
> **原则**: P0 先做、P1 跟进、P2 规划评估;每 Phase 一个交付周期
---
## 总览:20 项行动按 4 个 Phase 分组
```
Phase 0 (本周): 4 P0 项 — 基础设施补全,投入产出比最高
Phase 1 (2周内): 6 P0+P1 项 — 核心竞争力构建
Phase 2 (1月内): 5 P1 项 — 体验与护城河加固
Phase 3 (评估): 5 P2 项 — 按需投入
```
---
## Phase 0:基础设施补全(本周,4 项 P0)
这些是文档反复强调的"先决条件"——不做,后面的都没法高效开展。
### #0A. 多租户隔离(PG RLS + tenant_id)
**现状**: 平台无真正的多租户隔离。所有用户共享同一套数据,仅靠 JWT 区分身份。
**目标**: 启用 PostgreSQL Row Level Security,所有业务表加 `tenant_id`。
**工作量**: 3 天
**实施**:
- 核心表(conversations/messages/market_apps/skill_instances)加 `tenant_id UUID` 列
- 启用 `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`
- 创建 RLS 策略:`USING (tenant_id = current_setting('app.current_tenant')::UUID)`
- FastAPI middleware 注入 `app.current_tenant` 从 JWT 提取
- 知识库/资产存储目录结构改为 `/workspace/{tenant_id}/{user_id}/`
### #0B. LiteLLM Proxy — 统一 AI 网关
**现状**: 自定义 failover(`_try_models` 列表 + `continue` 切换),无成本追踪、无限流、无缓存。
**目标**: 引入 LiteLLM Proxy 作为网关,客户端零改动。
**工作量**: 3 天
**实施**:
- 部署 `litellm` 容器,配置 `litellm_config.yaml`
- 映射现有 4 个 provider(SiliconFlow/MiMo/AGNES/DeepSeek)
- 启用 Virtual Keys(按用户/租户分发 API Key,追踪花费)
- 启用 RPM/TPM 限流 + 加权负载均衡(替代 try-except failover)
- 启用 Redis 语义缓存(相同 prompt 不重复计费)
- litellm 端点替换 `DATABASE_URL` 配置中的模型 endpoint
### #0C. Meilisearch — 技能市场搜索引擎
**现状**: 技能市场搜索依赖 PostgreSQL `ILIKE` 查询,无中文分词、无 BM25、无分面筛选。
**目标**: 部署 Meilisearch 容器,替代 PG 全文搜索。
**工作量**: 2 天
**实施**:
- 部署 `meilisearch` 容器(含 jieba 中文分词)
- 创建 `market_apps` 索引(name/description/tags/category/capability/publisher)
- 全量同步现有 115 个技能到 Meilisearch
- 前端 MarketPage 搜索框改为调用 `/search` API(sub-50ms 响应)
- 支持 facets(category/capability/tags)+ 排序(downloads/rating/date)
- 后续可用 Tenant Tokens 做多租户搜索隔离
### #0D. 角色模型扩展(5 级 + 用户组)
**现状**: 仅 admin/user 两级,无 user groups、无模型/技能按组分配。
**目标**: 5 级角色 + 用户组机制。
**工作量**: 3 天
**实施**:
- 角色:super_admin / admin / developer / user / guest
- 用户组(Groups):用户可属于多个组;组级模型配额和技能可见性
- 用户组 API(CRUD + 成员管理)
- 前端 AdminLayout 角色判断(developer 可发布技能但不可管理用户)
---
## Phase 1:核心竞争力构建(2 周内,6 项 P0+P1)
### #1A. 技能真实执行器 — 3 个标杆技能 ⭐ 最重要
**现状**: `_execute_skill_tool()` 是"指令转发"——把 SKILL.md 的文字抄给 LLM,让 LLM "模拟执行"。这是平台最大短板。
**目标**: 落地真实执行器,3 个标杆技能跑通端到端。
**工作量**: 2 周
**实施**:
**Step 1: 执行器基础架构(3 天)**
- `SkillRuntime` 类:鉴权 → 创建工作区 → 调技能 API → 扣配额 → 存储产物 → 记录计量
- `SkillInstance` DB 模型:状态机(generating/ready/failed/archived)
- S3 兼容对象存储集成(MinIO 起步)
- 工作区隔离:`/workspace/{tenant_id}/{user_id}/{skill}/{instance_id}/`
**Step 2: 标杆技能 #1 — AI PPT 生成器(5 天)**
- 基于 dashi-ppt-skill 改造
- 适配层 API:`/api/generate` POST、`/api/export/pptx` GET、`/api/export/pdf` GET
- 输入:文本/文档 → LLM 生成大纲 → 渲染 HTML 幻灯片 → 导出 PPTX/PDF
- 预览图生成(headless Chrome 截图)
- 配额:Free 3次/天,Pro 50次/天
**Step 3: 标杆技能 #2 — SEO 优化助手(2 天)**
- 基于 marketing-skills 改造
- 输入:URL/关键词 → 分析 SERP → 输出 SEO 报告+优化建议
**Step 4: 标杆技能 #3 — AI 投研报告��2 天)**
- 基于 equity-research-skill 改造
- 输入:股票代码 → 财报分析 + DCF 估值 → 输出结构化投研报告
### #1B. 统一 SKILL.md Schema
**现状**: 技能元数据散乱,无统一格式/版本/权限/定价规范。
**目标**: 发布平台统一 SKILL.md 规范(9 大区块)。
**工作量**: 3 天
**实施**:
- Schema:name/version/author/description/capabilities/inputs/outputs/resource/permissions/dependencies/pricing
- 与现有 market_app DB 字段映射
- 自动解析器:`_parse_skill_content()` 增强,从 SKILL.md 提取结构
- 前端技能详情页显示结构化信息(输入/输出/配额/定价)
### #1C. 流式 Token 计费 + 实时余额
**现状**: 仅记录 Token 用量,无计费、无余额扣减、无配额提示。
**目标**: 实时 Token 计费 + 流式扣费 + 用户余额。
**工作量**: 5 天
**实施**:
- LiteLLM 集成后,每次调用自动获取 `usage.prompt_tokens + completion_tokens`
- 流式场景:累加 chunk 计数,`done` 事件时写入
- 用户余额表:`token_balances`
- 前端:对话中实时显示剩余 Token(顶部状态栏)
- 余额不足时提前拦截(不浪费一次模型调用)
### #1D. Agent 可视化编排画布 ⭐ 亮点
**现状**: Agent 编排靠 YAML 手写,无可视化界面。
**目标**: 基于 React Flow 的可拖拽编排画布。
**工作量**: 2 周
**实施**:
- 前端:React Flow 节点编辑器(节点库:LLM/Skill/MCP/External/Reduce/条件分支)
- 拖拽连线定义数据流
- 导出为 YAML → 对接现有 `run_graph()` 引擎
- 执行监控:实时高亮当前执行节点、查看输出
### #1E. 角色模型扩展(5 级 + 用户组)— 续 Phase 0D
(已在 Phase 0 中定义)
### #1F. 技能市场搜索 — 续 Phase 0C
(已在 Phase 0 中定义,Meilisearch)
---
## Phase 2:体验与护城河加固(1 月内,5 项 P1)
### #2A. 工具调用四级风险审批
**现状**: 仅 `tool_policy` 沙箱(白名单+预算+每轮上限),无操作风险分级。
**目标**: 参考 OpenWorker 的四级风险模型。
**工作量**: 3 天
**实施**:
- 风险等级:read / write_local / exec / external
- 每级定义默认行为(read 自动许可 / write_local 确认 / exec 审批 / external 管理员审批)
- 前端:高风险操作弹窗确认 + 审批后重试
- 审批队列:pending_approvals 表 + 管理员后台
### #2B. 引入 MemPalace 分层记忆
**现状**: 五类结构化记忆(实体/事实/偏好/决策/待办),无分层索引。
**目标**: Wing/Room/Drawer 分层索引(MemPalace)。
**工作量**: 1 周
**实施**:
- Wing = 用户级(跨所有对话)
- Room = 对话级(单次对话上下文)
- Drawer = 话题级(对话内特定话题)
- 跑 LongMemEval benchmark 量化召回率
### #2C. Loop 原语 + Verifier
**现状**: Agent 编排是 DAG 拓扑排序,无循环边、无验证器。
**目标**: 引入 Loop 原语(循环边 + 终止条件 + Verifier)。
**工作量**: 1 周
**实施**:
- `orchestrator.py` 支持循环边:`{"from": "verify", "to": "generate", "condition": "not_passed"}`
- Verifier 节点:判断目标是否达成的验证器
- 前端画布(#1D)支持画循环边
### #2D. executor 沙箱升级
**现状**: executor 容器基于 Docker,无严格内核隔离。
**目标**: gVisor 或 Firecracker 微虚拟机隔离。
**工作量**: 3 天
**评估**: gVisor 集成最简单(Docker 的 `--runtime=runsc`)
### #2E. 技能 SDK + 脚手架 CLI
**现状**: 技能开发者手动创建目录和 SKILL.md。
**目标**: `qihu-skill` CLI 脚手架。
**工作量**: 1 周
**实施**:
- `qihu-skill init my-skill` → 生成目录结构 + SKILL.md 模板
- `qihu-skill validate` → 校验 SKILL.md Schema
- `qihu-skill publish` → 打包上传到平台技能市场
- `qihu-skill dev` → 本地调试(Mock SkillRuntime)
---
## Phase 3:评估后按需投入(5 项,P2)
### #3A. Semantica 决策溯源
- 场景:金融/医疗/法律客户要求可解释 AI
- 工作量:评估 3 天 + 集成 1 周
- 决策标准:是否有付费企业客户明确要求
### #3B. Langfuse/LangSmith Agent 轨迹
- 场景:开发者调试 Agent 执行、产品优化
- 工作量:3 天集成
- 决策标准:Agent 可视化画布上线后,按需求优先级
### #3C. Agent 广播网络(EigenFlux)
- 场景:Agent 间自动发现与协作
- 工作量:评估
- 决策标准:多 Agent 协作需求明确后
### #3D. 前端暗色模式
- 工作量:3 天
- 决策标准:用户体验迭代批次
### #3E. 移动端适配
- 工作量:2 周
- 决策标准:移动端用户数据达标后
---
## 优先级总表(20 项排序)
| # | 行动 | 模块 | 优先级 | 工作量 | 依赖 |
|---|------|------|--------|--------|------|
| 1 | LiteLLM Proxy 统一 AI 网关 | 模型路由 | P0 | 3d | - |
| 2 | Meilisearch 技能市场搜索 | 搜索 | P0 | 2d | - |
| 3 | PG RLS + tenant_id 多租户隔离 | 多租户 | P0 | 3d | - |
| 4 | 角色 5 级 + 用户组 | 多租户 | P0 | 3d | #3 |
| **5** | **3 个标杆技能真实执行器** | **技能市场** | **P0** | **2w** | **-** |
| 6 | SKILL.md 统一 Schema | 技能市场 | P0 | 3d | - |
| 7 | 流式 Token 计费 + 实时余额 | 变现 | P0 | 5d | #1 |
| **8** | **Agent 可视化编排画布** | **Agent** | **P1** | **2w** | **-** |
| 9 | Loop 原语 + Verifier | Agent | P1 | 1w | #8 |
| 10 | 工具四级风险审批 | 安全 | P1 | 3d | - |
| 11 | executor gVisor 升级 | 安全 | P1 | 3d | - |
| 12 | MemPalace 分层记忆 | RAG | P1 | 1w | - |
| 13 | 技能 SDK + 脚手架 CLI | 技能市场 | P1 | 1w | #6 |
| 14 | LongMemEval 基准测试 | RAG | P1 | 2d | - |
| 15 | 技能审核流水线 | 技能市场 | P2 | 1w | #6 |
| 16 | 分润模型 + 开发者结算 | 变现 | P2 | 1w | #7 |
| 17 | Langfuse Agent 轨迹 | 可观测 | P2 | 3d | #1 |
| 18 | Semantica 决策溯源 | 安全 | P2 | 评估 | - |
| 19 | 前端暗色模式 | UI | P2 | 3d | - |
| 20 | 移动端适配 | UI | P2 | 2w | - |
---
## 竞品对比更新(基于分析文档)
| 维度 | 七虎AI 现状 | Open WebUI | Dify | 差距/优势 |
|------|------------|------------|------|----------|
| RBAC | admin/user 2 级 | Admin/User/Pending + Groups | 内置多租户 | 🔴 落后 |
| 向量库 | 仅 pgvector | 9 种可插拔 | 多种 | 🟡 够用 |
| RAG 管线 | 单路向量 | 3 阶段 BM25+向量+Rerank | 混合检索 | 🟡 待增强 |
| 插件体系 | Skills+MCP | 5 类插件 | 工作流节点 | 🟡 够用 |
| Token 计量 | 仅记录 | 实时+按模型+仪表盘 | 内置 | 🔴 落后 |
| 模型路由 | try-except | Pipe 路由 | 内置 failover | 🟡 待 LiteLLM |
| Agent 编排 | DAG 图引擎 | 无 | 工作流画布 | 🟢 领先 |
| 技能市场 | 上架-安装-执行 | 无 | 无 | 🟢 护城河 |
---
## 当前已完成(对标分析文档的"已修复弱点")
这些本期会话已修复,分析文档第九节引用:
| 修复 | 状态 | 对应文档章节 |
|------|------|------------|
| AGENT_GUIDE 标准化 | ✅ | §九 - 固定弱点已修复 |
| MCP 官方 SDK + Schema 转换 | ✅ | §九 |
| Tool Policy 白名单+预算+每轮上限 | ✅ | §九 |
| MiMo Token Plan 适配 | ✅ | - |
| `overflow-x: hidden` 响应式修复 | ✅ | - |
| 架构蓝图 `ARCHITECTURE.md` | ✅ | - |
---
> **执行指令**: 告诉我 "执行 Phase 0" 开始落地。每完成一项在对应 checkpoint 勾掉。
> **上传到 Gitea**: 本方案同步上传到 `qihuaiflow/docs/ITERATION_PLAN.md`
+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),
@@ -203,6 +203,7 @@
.front-main { .front-main {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
+112 -33
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>
<tbody> {extServices.length > 0 ? (
{extServices.map(svc => ( <div style={{ border: '1px solid #E5E7EB', borderRadius: 8, overflow: 'hidden' }}>
<tr key={svc.id}> <table className="admin-table" style={{ marginBottom: 0, width: '100%' }}>
<td>{svc.name}</td> <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>
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>Dify</span></td> <tbody>
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td> {extServices.map(svc => (
<td>{statusBadge(svc.status)}</td> <tr key={svc.id}>
<td> <td>{svc.name}</td>
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button> <td><span style={{ fontSize: 12, padding: '2px 8px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 4 }}>Dify</span></td>
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button> <td style={{ fontSize: 12, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.api_url}</td>
</td> <td>{statusBadge(svc.status)}</td>
</tr> <td style={{ textAlign: 'center' }}>
))} <button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
</tbody> <button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 6 }}>删除</button>
</table> </td>
</div> </tr>
)} ))}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}> </tbody>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>配置 Dify 知识库</h4> </table>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> </div>
) : (
<div style={{ padding: 16, background: '#F9FAFB', borderRadius: 8, color: '#6B7280', fontSize: 13 }}>暂无外部服务,可在下方添加 Dify 知识库。</div>
)}
</div>
{/* 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>
+233 -1
View File
@@ -471,10 +471,15 @@
} }
.md-table td { .md-table td {
padding: 10px 14px; padding: 8px 12px;
border-bottom: 1px solid #EEF0F4; border-bottom: 1px solid #EEF0F4;
color: var(--color-text-primary); color: var(--color-text-primary);
vertical-align: top; vertical-align: top;
line-height: 1.5;
}
.md-table td br {
line-height: 0.8;
} }
.md-table tbody tr:last-child td { .md-table tbody tr:last-child td {
@@ -1552,3 +1557,230 @@ 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-fresh {
animation: aiProcessPulse 1.2s ease-out;
}
.ai-process-fresh .ai-process-summary {
color: var(--color-accent, #5B5FED);
font-weight: 700;
}
@keyframes aiProcessPulse {
0% {
box-shadow: 0 0 0 0 rgba(91, 95, 237, 0.4);
border-color: var(--color-accent, #5B5FED);
}
50% {
box-shadow: 0 0 0 6px rgba(91, 95, 237, 0.08);
border-color: color-mix(in srgb, var(--color-accent, #5B5FED) 60%, transparent);
}
100% {
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);
}
/* ===== AI 回复异常状态 ===== */
.ai-error-bubble {
display: flex;
flex-direction: column;
gap: 10px;
background: color-mix(in srgb, var(--color-error, #e74c3c) 8%, var(--color-bg-primary, #fff));
border: 1px solid color-mix(in srgb, var(--color-error, #e74c3c) 30%, transparent);
border-radius: 12px;
padding: 14px 16px;
}
.ai-error-icon {
font-size: 18px;
margin-bottom: -6px;
}
.ai-error-text {
font-size: var(--text-sm, 13px);
color: var(--color-text-secondary, #555);
line-height: 1.6;
}
.ai-error-text p {
margin: 0 0 4px;
}
.ai-error-retry {
align-self: flex-start;
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: var(--color-accent, #5B5FED);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, transform 0.15s;
}
.ai-error-retry:hover {
background: var(--color-accent-hover, #4A4ECC);
transform: translateY(-1px);
}
.ai-error-retry:active {
transform: scale(0.97);
}
/* 暗色主题 */
[data-theme="dark"] .ai-error-bubble,
html.dark .ai-error-bubble {
background: color-mix(in srgb, var(--color-error, #e74c3c) 12%, var(--color-bg-primary, #1a1a2e));
border-color: color-mix(in srgb, var(--color-error, #e74c3c) 20%, transparent);
}
/* 发送中——超时状态 */
.ai-sending-timeout {
border-color: color-mix(in srgb, #f39c12 30%, transparent) !important;
background: color-mix(in srgb, #f39c12 5%, var(--color-bg-primary, #fff)) !important;
}
[data-theme="dark"] .ai-sending-timeout,
html.dark .ai-sending-timeout {
border-color: color-mix(in srgb, #f39c12 25%, transparent) !important;
background: color-mix(in srgb, #f39c12 8%, var(--color-bg-primary, #1a1a2e)) !important;
}
.ai-cancel-btn:hover {
background: color-mix(in srgb, #e74c3c 15%, var(--color-bg-tertiary)) !important;
}
/* ===== Phase 0 SSE 流式:思考链实时渲染 ===== */
/* 思考链文本保留模型原始换行(deepseek 原生 reasoning 含多段换行) */
.ai-process-thinking .ai-process-text {
white-space: pre-wrap;
}
/* 流式生成中提示 */
.streaming-hint {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 14px;
}
.streaming-hint svg {
width: 16px;
height: 16px;
}
.ai-process-running { animation: aiPulse 1.5s ease-in-out infinite; }
@keyframes aiPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
.ai-process-error { background: rgba(255,0,0,0.05); border-left: 2px solid #e74c3c; }
.ai-process-running { border-left: 2px solid #3498db; }
+472 -251
View File
@@ -6,17 +6,34 @@ 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';
mermaid.initialize({ startOnLoad: false, theme: 'default' }); mermaid.initialize({ startOnLoad: false, theme: 'default' });
// ===== AI 回复异常检测 =====
const AI_ERROR_PATTERNS = [
'AI模型调用失败',
'AI服务暂时不可用',
'AI模型返回了空响应',
'AI模型已处理工具调用结果',
'该模型缺少API密钥或端点配置',
'请选择一个已配置的AI模型',
'请先在后台管理-大模型库配置默认对话模型',
'⚠️ 多 Agent 编排执行失败',
'⚠️ 外部 Agent 调用失败',
'编排未产生内容',
'外部 Agent 未返回内容',
];
function isAiError(content: string): boolean {
if (!content) return false;
return AI_ERROR_PATTERNS.some(p => content.includes(p));
}
// ===== 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,172 +171,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.7 Extract Markdown tables: | h1 | h2 |\n|---|---| ... |
// 把整段表格替换为占位符,表格内容(已转义)放入 tableBlocks 数组
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 bodyRows = lines.slice(2).map((row: string) =>
row.split('|').slice(1, -1).map((c: string) => c.trim())
);
const thead = headerCells.map((c: string, i: number) =>
`<th style="text-align:${alignCells[i] || 'left'}">${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';
}
);
// 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>');
// 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 to <br/>
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, '<br/>');
// 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;
} }
@@ -329,7 +191,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(() => {
@@ -387,14 +249,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);
@@ -622,6 +476,149 @@ const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '',
); );
}; };
// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
// 6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
// 展开时自动滚动到视图内,带脉冲动画提示用户注意。
const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
const [open, setOpen] = useState(autoExpand);
const [fresh, setFresh] = useState(autoExpand);
const timerRef = useRef<number | null>(null);
const touchedRef = useRef(false);
const blockRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
if (!autoExpand) {
setOpen(false);
setFresh(false);
return;
}
setOpen(true);
setFresh(true);
touchedRef.current = false;
// 自动滚动到视图内(上方留 60px 避免被顶部遮挡)
if (blockRef.current) {
blockRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
timerRef.current = window.setTimeout(() => {
if (!touchedRef.current) {
setOpen(false);
// 收拢后短暂保留 fresh 样式再移除
setTimeout(() => setFresh(false), 500);
}
}, 6000);
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, [autoExpand]);
if (!steps || steps.length === 0) return null;
const toggle = () => {
touchedRef.current = true;
setFresh(false);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
setOpen(o => !o);
};
return (
<details ref={blockRef} className={`ai-process${open ? '' : ''}${fresh ? ' ai-process-fresh' : ''}`} open={open}>
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
<span className="ai-process-badge">🧠</span>
AI 思考 / 执行过程({steps.length} 步 — {open ? '自动收拢中... 点击手动收起' : '点击展开查看'})
</summary>
<div className="ai-process-list">
{steps.map((s: any, i: number) => {
const isLast = i === steps.length - 1;
const hasResult = s.result && s.result.length > 0;
const isError = hasResult && (s.result.includes('error') || s.result.includes('失败') || s.result.includes('异常') || s.result.includes('拦截'));
const isRunning = isLast && !hasResult;
const stepIcon = s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌';
const statusIcon = isRunning ? ' ⏳' : isError ? ' ❌' : hasResult ? ' ✅' : '';
const resultDisplay = (s.result || '').length > 400 ? (s.result || '').slice(0, 400) + '...' : (s.result || '');
return (
<div key={i} className={`ai-process-item ai-process-${s.type}${isRunning ? ' ai-process-running' : ''}${isError ? ' ai-process-error' : ''}`}>
<span className="ai-process-ico">{stepIcon}{statusIcon}</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}{statusIcon}</b>
{s.args && Object.keys(s.args).length > 0 && (
<span style={{fontSize:'12px',color:'#666',margin:'2px 0',display:'block'}}>
参数: {JSON.stringify(s.args).slice(0, 200)}
</span>
)}
{hasResult && <p className="ai-process-text">{resultDisplay}</p>}
</>)}
{s.type === 'mcp' && (<>
<b className="ai-process-title">MCP · {s.server}:{s.name}{statusIcon}</b>
{s.args && Object.keys(s.args).length > 0 && (
<span style={{fontSize:'12px',color:'#666',margin:'2px 0',display:'block'}}>
参数: {JSON.stringify(s.args).slice(0, 200)}
</span>
)}
{hasResult && <p className="ai-process-text">{resultDisplay}</p>}
</>)}
</div>
</div>
)})}
</div>
</details>
);
};
// 思考过程收拢式展示(缺陷5:默认折叠;fresh 流式消息自动展开,数秒后自动收拢)
const ReasoningBlock: React.FC<{ text: string; fresh: boolean }> = ({ text, fresh }) => {
const [open, setOpen] = useState(fresh);
const [isFresh, setFreshState] = useState(fresh);
const touchedRef = useRef(false);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!fresh) {
setOpen(false);
setFreshState(false);
return;
}
setOpen(true);
setFreshState(true);
touchedRef.current = false;
timerRef.current = window.setTimeout(() => {
if (!touchedRef.current) {
setOpen(false);
setTimeout(() => setFreshState(false), 500);
}
}, 5000);
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [fresh]);
if (!text) return null;
const toggle = () => {
touchedRef.current = true;
setFreshState(false);
if (timerRef.current) window.clearTimeout(timerRef.current);
setOpen(o => !o);
};
return (
<details className={`ai-process ai-process-reasoning${isFresh ? ' ai-process-fresh' : ''}`} open={open}>
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
<span className="ai-process-badge">🤔</span>
AI 思考过程{open ? '(收拢中... 点击收起)' : '(点击展开)'}
</summary>
<div className="ai-process-list">
<div className="ai-process-item ai-process-thinking">
<span className="ai-process-ico">💡</span>
<div className="ai-process-body">
<p className="ai-process-text">{text}</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 }>();
@@ -631,10 +628,19 @@ const ChatPage: React.FC = () => {
const [chatModels, setChatModels] = useState<any[]>([]); const [chatModels, setChatModels] = useState<any[]>([]);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sendingTimeout, setSendingTimeout] = useState(false);
const sendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡
const [streamingId, setStreamingId] = useState<string | null>(null);
// 流式中断控制器(缺陷4:支持「停止生成」)
const abortRef = useRef<AbortController | null>(null);
const stopStreaming = () => { try { abortRef.current?.abort(); } catch { /* noop */ } };
const [conversations, setConversations] = useState<Conversation[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
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[]>([]);
@@ -1003,11 +1009,227 @@ const ChatPage: React.FC = () => {
} }
}; };
// Handle send message - CALLS REAL API // 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退
const sendLegacy = async (
payload: any,
tempUserMsg: Message,
) => {
const res = await chatAPI.sendMessage(selectedConversation!.id, payload);
const data = unwrapData(res);
// Replace temporary user message and add assistant response
setMessages(prev => {
const filtered = prev.filter(m => m.id !== tempUserMsg.id);
const newMsgs: Message[] = [];
if (data.user_message) {
newMsgs.push({
id: data.user_message.id,
conversationId: selectedConversation?.id || '',
role: 'user',
content: data.user_message.content,
tokenUsed: data.user_message.token_used || 0,
createdAt: data.user_message.created_at || new Date().toISOString(),
});
}
if (data.assistant_message) {
newMsgs.push({
id: data.assistant_message.id,
conversationId: selectedConversation?.id || '',
role: 'assistant',
content: data.assistant_message.content,
tokenUsed: data.assistant_message.token_used || 0,
createdAt: data.assistant_message.created_at || new Date().toISOString(),
used_skills: data.assistant_message.used_skills || [],
used_mcp: data.assistant_message.used_mcp || [],
process_steps: data.assistant_message.process_steps || [],
});
}
return [...filtered, ...newMsgs];
});
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
if (data.assistant_message) {
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
}
// 媒体异步生成:启动轮询,待后端回写后自动刷新
if (data.assistant_message?.generating) {
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
}
// Refresh conversations to update lastMessage
loadConversations();
// 发送后清空本次附件(选择状态仍保留)
setAttachments([]);
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
if (selectedConversation?.id && data.assistant_message) {
const recentMsgs = [
...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
{ role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
];
evolutionAPI.submitTrajectory({
conversation_id: selectedConversation.id,
messages: recentMsgs,
}).catch(() => {});
}
};
// SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning)
const streamSend = async (
convId: string,
payload: any,
tempUserMsg: Message,
assistantId: string,
): Promise<void> => {
const token = localStorage.getItem('aiflow_token');
const controller = new AbortController();
abortRef.current = controller;
const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!resp.ok || !resp.body) {
throw new Error(`SSE 连接失败 (${resp.status})`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let accReasoning = '';
let gotDone = false;
let pendingDelta = '';
let reasoningDirty = false;
let rafId: number | null = null;
// 缺陷4:用 requestAnimationFrame 合并同一帧内的多个 delta/reasoning,避免逐 token 重渲染抖动
const flushBuffered = () => {
rafId = null;
if (!pendingDelta && !reasoningDirty) return;
const piece = pendingDelta;
pendingDelta = '';
const r = reasoningDirty ? accReasoning : null;
reasoningDirty = false;
setMessages(prev => prev.map(m => {
if (m.id !== assistantId) return m;
const patch: any = {};
if (piece) patch.content = (m.content || '') + piece;
if (r !== null) patch.reasoning = r;
return { ...m, ...patch };
}));
};
const scheduleFlush = () => {
if (rafId == null) rafId = requestAnimationFrame(flushBuffered);
};
const finalizeFlush = () => {
if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; }
flushBuffered();
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const dataLine = raw.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
let evt: any;
try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
if (evt.type === 'delta') {
pendingDelta += evt.content || '';
scheduleFlush();
} else if (evt.type === 'reasoning') {
accReasoning += evt.content || '';
reasoningDirty = true;
scheduleFlush();
} else if (evt.type === 'step') {
// 缺陷2:实时增量过程步骤,前端随事件收拢式渲染
const step = evt.step;
const idx = typeof evt.index === 'number' ? evt.index : -1;
setMessages(prev => prev.map(m => {
if (m.id !== assistantId) return m;
const steps: any[] = (m as any).process_steps || [];
const next = steps.slice();
if (idx >= 0 && idx < next.length) next[idx] = step;
else next.push(step);
return { ...m, process_steps: next };
}));
} else if (evt.type === 'done') {
gotDone = true;
finalizeFlush();
const finalId = evt.message_id || assistantId;
setMessages(prev => prev.map(m => {
if (m.id !== assistantId) return m;
return {
...m,
id: finalId,
content: evt.content ?? m.content,
process_steps: evt.process_steps || [],
used_skills: evt.used_skills || [],
used_mcp: evt.used_mcp || [],
generating: !!evt.generating,
};
}));
setStreamingId(null);
abortRef.current = null;
if (evt.generating) startMediaPolling(convId, finalId);
setFreshMsgIds(prev => new Set(prev).add(finalId));
loadConversations();
setAttachments([]);
if (convId && evt.content) {
evolutionAPI.submitTrajectory({
conversation_id: convId,
messages: [
{ role: 'user', content: tempUserMsg.content, tokens: 0 },
{ role: 'assistant', content: evt.content, tokens: 0 },
],
}).catch(() => {});
}
} else if (evt.type === 'error') {
// 缺陷4:容错——保留已生成内容,不强删,标记中断后优雅收尾
finalizeFlush();
setMessages(prev => prev.map(m => {
if (m.id !== assistantId) return m;
const note = '\n\n_(生成中断:' + (evt.message || '未知错误') + ')_';
return { ...m, content: (m.content || '') + note };
}));
setStreamingId(null);
abortRef.current = null;
setFreshMsgIds(prev => new Set(prev).add(assistantId));
loadConversations();
gotDone = true;
return;
}
}
}
if (!gotDone) {
// 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底
finalizeFlush();
abortRef.current = null;
setStreamingId(null);
throw new Error('流式响应未正常结束');
}
};
// Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式)
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用 // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
const sendText = async (content: string) => { const sendText = async (content: string) => {
if (!content.trim() || sending || !selectedConversation) return; if (!content.trim() || sending || !selectedConversation) return;
setSending(true); setSending(true);
setSendingTimeout(false);
// 60s 超时警告
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
sendingTimerRef.current = setTimeout(() => {
setSendingTimeout(true);
}, 60000);
// Add user message optimistically // Add user message optimistically
const tempUserMsg: Message = { const tempUserMsg: Message = {
@@ -1020,78 +1242,48 @@ const ChatPage: React.FC = () => {
}; };
setMessages(prev => [...prev, tempUserMsg]); setMessages(prev => [...prev, tempUserMsg]);
const payload = {
content,
model_id: selectedModelId || undefined,
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
name: a.name, file_type: a.file_type, content: a.content || '',
})) : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
};
// 先放一个空的助手消息占位,用于 SSE 流式增量渲染
const assistantId = `temp_assistant_${Date.now()}`;
setMessages(prev => [...prev, {
id: assistantId,
conversationId: selectedConversation!.id,
role: 'assistant',
content: '',
tokenUsed: 0,
createdAt: new Date().toISOString(),
reasoning: '',
} as Message]);
setStreamingId(assistantId);
try { try {
const res = await chatAPI.sendMessage(selectedConversation!.id, { await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId);
content,
model_id: selectedModelId || undefined,
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
name: a.name, file_type: a.file_type, content: a.content || '',
})) : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
});
const data = unwrapData(res);
// Replace temporary user message and add assistant response
setMessages(prev => {
const filtered = prev.filter(m => m.id !== tempUserMsg.id);
const newMsgs: Message[] = [];
if (data.user_message) {
newMsgs.push({
id: data.user_message.id,
conversationId: selectedConversation?.id || '',
role: 'user',
content: data.user_message.content,
tokenUsed: data.user_message.token_used || 0,
createdAt: data.user_message.created_at || new Date().toISOString(),
});
}
if (data.assistant_message) {
newMsgs.push({
id: data.assistant_message.id,
conversationId: selectedConversation?.id || '',
role: 'assistant',
content: data.assistant_message.content,
tokenUsed: data.assistant_message.token_used || 0,
createdAt: data.assistant_message.created_at || new Date().toISOString(),
used_skills: data.assistant_message.used_skills || [],
used_mcp: data.assistant_message.used_mcp || [],
});
}
return [...filtered, ...newMsgs];
});
// 媒体异步生成:启动轮询,待后端回写后自动刷新
if (data.assistant_message?.generating) {
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
}
// Refresh conversations to update lastMessage
loadConversations();
// 发送后清空本次附件(选择状态仍保留)
setAttachments([]);
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
if (selectedConversation?.id && data.assistant_message) {
const recentMsgs = [
...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []),
{ role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 },
];
evolutionAPI.submitTrajectory({
conversation_id: selectedConversation.id,
messages: recentMsgs,
}).catch(() => {});
}
} catch (err: any) { } catch (err: any) {
// Remove optimistic message on error // 移除流式占位,回退非流式重新发送
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id));
alert('发送失败: ' + (err.message || err.detail || '未知错误')); setStreamingId(null);
try {
await sendLegacy(payload, tempUserMsg);
} catch (e2: any) {
setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id));
alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误'));
}
} finally { } finally {
if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; }
setSending(false); setSending(false);
setSendingTimeout(false);
inputRef.current?.focus(); inputRef.current?.focus();
} }
}; };
@@ -1333,18 +1525,38 @@ 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)} />
)}
{msg.role === 'assistant' && (msg as any).reasoning && (
<ReasoningBlock text={(msg as any).reasoning} fresh={freshMsgIds.has(msg.id)} />
)}
<div className={`message-bubble ${msg.role}`}> <div className={`message-bubble ${msg.role}`}>
{clarify ? ( {clarify ? (
<ClarifyCard <ClarifyCard
data={clarify} data={clarify}
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)} onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
/> />
) : msg.role === 'assistant' && isAiError(msg.content) ? (
<div className="ai-error-bubble">
<div className="ai-error-icon">⚠️</div>
<div className="ai-error-text">
<MarkdownContent content={msg.content} />
</div>
<button className="ai-error-retry" onClick={() => {
setMessages(prev => prev.filter(m => m.id !== msg.id));
const msgsAfterFilter = messages.filter(m => m.id !== msg.id);
const lastUser = [...msgsAfterFilter].reverse().find(m => m.role === 'user');
if (lastUser) sendText(lastUser.content);
}}>🔄 重试</button>
</div>
) : msg.role === 'assistant' ? ( ) : msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} /> msg.content ? <MarkdownContent content={msg.content} /> : (streamingId === msg.id ? <span className="streaming-hint"><IconSpinner /> 生成中…</span> : null)
) : ( ) : (
<span>{msg.content}</span> <span>{msg.content}</span>
)} )}
@@ -1408,11 +1620,20 @@ const ChatPage: React.FC = () => {
); );
}) })
)} )}
{sending && ( {sending && !streamingId && (
<div className="message-row assistant"> <div className="message-row assistant">
<div className="message-bubble assistant" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className={`message-bubble assistant${sendingTimeout ? ' ai-sending-timeout' : ''}`} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSpinner /> <IconSpinner />
<span>思考中...</span> <span>{sendingTimeout ? '⏳ 响应时间较长,AI 仍在处理中...' : '思考中...'}</span>
{sendingTimeout && (
<button className="ai-cancel-btn" onClick={() => {
setSending(false);
setSendingTimeout(false);
if (sendingTimerRef.current) clearTimeout(sendingTimerRef.current);
}} title="取消等待" style={{ marginLeft: 12, padding: '2px 8px', fontSize: 12, cursor: 'pointer', background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-secondary)' }}>
取消
</button>
)}
</div> </div>
</div> </div>
)} )}
@@ -1603,11 +1824,11 @@ const ChatPage: React.FC = () => {
</button> </button>
<button <button
className="send-btn" className="send-btn"
onClick={handleSend} onClick={streamingId ? stopStreaming : handleSend}
disabled={!inputValue.trim() || sending} disabled={!streamingId && (!inputValue.trim() || sending)}
title="发送 (Enter)" title={streamingId ? '停止生成' : '发送 (Enter)'}
> >
{sending ? <IconSpinner /> : <IconSend />} {streamingId ? <IconStop /> : (sending ? <IconSpinner /> : <IconSend />)}
</button> </button>
</div> </div>
</div> </div>
@@ -1000,51 +1000,7 @@
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.evo-anim-toggle { /* 技能图谱动画已在 #717 中移除(边框光晕溢出且无实际用途) */
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
cursor: pointer;
user-select: none;
}
.evo-anim-toggle input {
width: 14px;
height: 14px;
accent-color: var(--color-accent);
cursor: pointer;
}
/* Baby sprout animation */
.evo-skill-node.baby.anim-on .evo-node-dot {
animation: baby-sprout 2s ease-in-out infinite;
}
.evo-skill-node.baby.anim-on {
animation: baby-glow 2s ease-in-out infinite;
}
@keyframes baby-sprout {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
@keyframes baby-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3); }
50% { box-shadow: 0 0 16px 4px rgba(245, 158, 11, 0); }
}
/* Staging breathe animation */
.evo-skill-node.staging.anim-on {
animation: staging-breathe 3s ease-in-out infinite;
}
@keyframes staging-breathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.06); opacity: 1; }
}
/* Toast */ /* Toast */
.evo-toast { .evo-toast {
@@ -1535,47 +1491,7 @@
} }
} }
/* ===== v2: Baby Sprout Animation (GAP-03) ===== */ /* ===== v2: 技能图谱动画已在 #717 中移除(边框溢出且无实际用途) ===== */
.evo-skill-node.state-baby.anim-on {
animation: baby-sprout 2s ease-in-out infinite;
}
@keyframes baby-sprout {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 182, 193, 0.4);
}
50% {
transform: scale(1.15);
box-shadow: 0 0 20px 4px rgba(255, 182, 193, 0);
}
}
/* Baby skill sprout icon */
.evo-skill-node.state-baby.anim-on .evo-node-dot::after {
content: '🌱';
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
animation: sprout-wiggle 1.5s ease-in-out infinite;
}
@keyframes sprout-wiggle {
0%, 100% { transform: translateX(-50%) rotate(-5deg); }
50% { transform: translateX(-50%) rotate(5deg); }
}
/* Staging breathe animation */
.evo-skill-node.state-staging.anim-on {
animation: staging-breathe 3s ease-in-out infinite;
}
@keyframes staging-breathe {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.08); opacity: 1; }
}
/* ===== v2: Toast messages ===== */ /* ===== v2: Toast messages ===== */
.evo-toast { .evo-toast {
@@ -62,9 +62,7 @@ const shareThemes = [
// ==================== Panel A: Skill Tree ==================== // ==================== Panel A: Skill Tree ====================
const SkillTreePanel: React.FC<{ const SkillTreePanel: React.FC<{
skills: EvolutionSkill[]; skills: EvolutionSkill[];
animEnabled: boolean; }> = ({ skills }) => {
onToggleAnim: () => void;
}> = ({ skills, animEnabled, onToggleAnim }) => {
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null); const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null); const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
const [showVersionHistory, setShowVersionHistory] = useState(false); const [showVersionHistory, setShowVersionHistory] = useState(false);
@@ -136,14 +134,6 @@ const SkillTreePanel: React.FC<{
{/* Publish toast */} {/* Publish toast */}
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>} {publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
{/* Anim Toggle */}
<div className="evo-panel-toolbar">
<label className="evo-anim-toggle">
<input type="checkbox" checked={animEnabled} onChange={onToggleAnim} />
<span>动画效果</span>
</label>
</div>
{/* Skill detail modal */} {/* Skill detail modal */}
{selectedSkill && !showVersionHistory && ( {selectedSkill && !showVersionHistory && (
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}> <div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
@@ -305,7 +295,7 @@ const SkillTreePanel: React.FC<{
<React.Fragment key={skill.id}> <React.Fragment key={skill.id}>
{idx > 0 && <div className="evo-node-connector" />} {idx > 0 && <div className="evo-node-connector" />}
<div <div
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`} className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredSkill(skill.id)} onMouseEnter={() => setHoveredSkill(skill.id)}
onMouseLeave={() => setHoveredSkill(null)} onMouseLeave={() => setHoveredSkill(null)}
onClick={() => setSelectedSkill(skill)} onClick={() => setSelectedSkill(skill)}
@@ -944,22 +934,10 @@ const EvolutionDashboard: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null); const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
const [animEnabled, setAnimEnabled] = useState(() => {
return localStorage.getItem('evo_anim_enabled') !== 'false';
});
const totalSkills = skills.length; const totalSkills = skills.length;
const mainCount = skills.filter(s => s.state === 'main').length; const mainCount = skills.filter(s => s.state === 'main').length;
const babyCount = skills.filter(s => s.state === 'baby').length; const babyCount = skills.filter(s => s.state === 'baby').length;
const toggleAnim = useCallback(() => {
setAnimEnabled(prev => {
const next = !prev;
localStorage.setItem('evo_anim_enabled', String(next));
return next;
});
}, []);
const handleViewSkill = (skillName: string) => { const handleViewSkill = (skillName: string) => {
const skill = skills.find(s => s.skill_name === skillName); const skill = skills.find(s => s.skill_name === skillName);
if (skill) { if (skill) {
@@ -1071,7 +1049,7 @@ const EvolutionDashboard: React.FC = () => {
))} ))}
</aside> </aside>
<main className="evo-main"> <main className="evo-main">
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} animEnabled={animEnabled} onToggleAnim={toggleAnim} />} {activePanel === 'skill-tree' && <SkillTreePanel skills={skills} />}
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />} {activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)} {activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)} {activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
+20
View File
@@ -1805,3 +1805,23 @@
font-weight: 400; font-weight: 400;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
} }
/* 官方内置技能徽标(见 #716:官方导入并发布的技能默认全局已安装) */
.modal-official-badge,
.market-card-official-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
line-height: 18px;
border-radius: 8px;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
white-space: nowrap;
}
.modal-official-badge {
margin-left: auto;
}
+9 -2
View File
@@ -337,6 +337,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
setInstalled(!!app.installed); setInstalled(!!app.installed);
}, [app.id, app.installed]); }, [app.id, app.installed]);
// 官方导入并发布的技能默认全局已安装,前端展示为「官方内置」(见 #716)
const isOfficialBuiltin = app.source === 'official' && app.status === 'published';
useEffect(() => { useEffect(() => {
// 加载评论 // 加载评论
marketAPI.getReviews(app.id).then((res: any) => { marketAPI.getReviews(app.id).then((res: any) => {
@@ -463,7 +466,9 @@ const AppDetailModal: React.FC<AppDetailModalProps> = ({ app, onClose, onAppChan
<span className="modal-price">{app.price} 元/月</span> <span className="modal-price">{app.price} 元/月</span>
)} )}
</div> </div>
{installed ? ( {isOfficialBuiltin ? (
<span className="modal-official-badge">官方内置</span>
) : installed ? (
<button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}> <button className="modal-install-btn uninstall" onClick={handleUninstall} disabled={uninstalling}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@@ -1949,7 +1954,9 @@ const MarketPage: React.FC = () => {
<div className="market-card-bottom"> <div className="market-card-bottom">
<StarRating rating={app.ratingAvg} count={app.reviewCount} /> <StarRating rating={app.ratingAvg} count={app.reviewCount} />
<div className="market-card-right"> <div className="market-card-right">
{app.installed ? ( {app.source === 'official' && app.status === 'published' ? (
<span className="market-card-official-badge">官方内置</span>
) : app.installed ? (
<button <button
className="market-card-uninstall-btn" className="market-card-uninstall-btn"
onClick={(e) => { onClick={(e) => {
@@ -3,6 +3,12 @@
padding: var(--space-3xl) var(--space-4xl); padding: var(--space-3xl) var(--space-4xl);
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
/* 见 #714:FrontLayout 的 .front-main 裁剪了主内容区,
此处让页面自身在可用高度内纵向滚动,避免内容被截断无法上下查阅 */
height: 100%;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
animation: fadeIn var(--transition-normal); animation: fadeIn var(--transition-normal);
} }
@@ -329,6 +335,8 @@
.tkd-table-scroll { .tkd-table-scroll {
overflow-x: auto; overflow-x: auto;
overflow-y: auto;
max-height: 60vh;
padding: var(--space-lg) var(--space-xl) var(--space-sm); padding: var(--space-lg) var(--space-xl) var(--space-sm);
} }
+2
View File
@@ -112,6 +112,7 @@ html {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
overflow-x: hidden;
} }
body { body {
@@ -120,6 +121,7 @@ body {
background: var(--color-bg-primary); background: var(--color-bg-primary);
line-height: 1.6; line-height: 1.6;
overflow: hidden; overflow: hidden;
overflow-x: hidden;
height: 100vh; height: 100vh;
} }
+8
View File
@@ -60,8 +60,16 @@ export interface Message {
content: string; content: string;
tokenUsed: number; tokenUsed: number;
createdAt: string; createdAt: string;
reasoning?: 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;