chore(align): sync Git with running container as authoritative source
One-time alignment to close drift between the running container (qihu-aiflow / image qihuaiflow:with-deps) and this Git repository. Container hotfixes previously persisted only via `docker commit` are now captured in Git so Git becomes the single source of truth. Backend: - chat_service.py: accumulated fixes incl. EigenFlux README guidance - chat_context.py / chat_media.py: URL/web-repo context injection - api/url_reader.py: NEW SSRF-safe web/README reader (Issue #725) - skills_admin.py & Skills.tsx: 14-scene capability unification - pgvector_search.py, orchestrator.py, platform_knowledge.py, market.py, admin/models.py: drift sync Frontend: - ChatPage.tsx: collapsible AI think/act block (Issue #724) - MarketPage / EvolutionDashboard / TokenDashboard styling sync Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-23 05:42:47. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
49f7503978
commit
a08ec87b22
19 files changed
+7474
-7196
No files matched your search
@@ -932,7 +932,13 @@ async def test_model_connection(
|
|||||||
"n": 1,
|
"n": 1,
|
||||||
"size": "1024x1024",
|
"size": "1024x1024",
|
||||||
}
|
}
|
||||||
resp = await client.post(image_url, json=payload, headers=headers)
|
# 与运行时媒体生成一致:用 _proxy_for_url 解析代理并 trust_env=False;
|
||||||
|
# 图像端点会被 _proxy_for_url 判定为直连(公网 API 直连比代理更稳定)
|
||||||
|
from ..chat_utils import _proxy_for_url
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout, proxy=_proxy_for_url(image_url), trust_env=False
|
||||||
|
) as img_client:
|
||||||
|
resp = await img_client.post(image_url, json=payload, headers=headers)
|
||||||
latency = int((time.time() - start) * 1000)
|
latency = int((time.time() - start) * 1000)
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1121,7 +1121,7 @@ def _build_system_prompt(
|
|||||||
|
|
||||||
all_mcp: list = None,
|
all_mcp: list = None,
|
||||||
|
|
||||||
|
db: Session = None,
|
||||||
|
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
|
||||||
@@ -1195,7 +1195,7 @@ def _build_system_prompt(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
pk = get_platform_knowledge()
|
pk = get_platform_knowledge(db=db)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1566,11 +1566,16 @@ async def _generate_media_task(ai_msg_id: str, ai_model_id: str, prompt: str, us
|
|||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning(f"[_generate_media_task] 后台生成异常: {e}", exc_info=True)
|
||||||
|
# 兜底回写:任何意外异常都不能让消息永久停在「⏳ 生成中…」
|
||||||
|
try:
|
||||||
logger.warning(f"[_generate_media_task] 后台生成异常: {e}")
|
db.rollback()
|
||||||
|
_err = db.query(Message).filter(Message.id == ai_msg_id).first()
|
||||||
|
if _err and str(_err.content or "").startswith("⏳"):
|
||||||
|
_err.content = "⚠️ 媒体生成失败,请稍后重试"
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+4304
-4273
File diff suppressed because it is too large.
Load diff
@@ -1,387 +1,387 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
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
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from ..database import get_db, SessionLocal
|
from ..database import get_db, SessionLocal
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
MCPServer,
|
MCPServer,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gen_uuid
|
gen_uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..platform_knowledge import get_platform_knowledge
|
from ..platform_knowledge import get_platform_knowledge
|
||||||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||||||
from ..middleware.log_service import LogService
|
from ..middleware.log_service import LogService
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CST_TZ = timezone(timedelta(hours=8))
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
def cst_now():
|
def cst_now():
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""Get current time in CST timezone"""
|
"""Get current time in CST timezone"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return datetime.now(CST_TZ)
|
return datetime.now(CST_TZ)
|
||||||
|
|
||||||
def _html_escape(s: str) -> str:
|
def _html_escape(s: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.replace('"', """).replace("'", "'"))
|
.replace('"', """).replace("'", "'"))
|
||||||
|
|
||||||
def _outbound_proxy() -> str:
|
def _outbound_proxy() -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""出网代理地址(兜底)。默认经 172.18.0.1:7890 智能路由代理;
|
"""出网代理地址(兜底)。默认经 172.18.0.1:7890 智能路由代理;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
运行时若未注入代理环境变量则回退到该地址。"""
|
运行时若未注入代理环境变量则回退到该地址。"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
||||||
|
|
||||||
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
||||||
|
|
||||||
def _proxy_for_url(url: str) -> str | None:
|
def _proxy_for_url(url: str) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""按目标域名选择是否走代理。
|
"""按目标域名选择是否走代理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2026-07-15 调整:AGNES(apihub.agnes-ai.com / platform-outputs.agnes-ai.space)
|
2026-07-15 调整:AGNES(apihub.agnes-ai.com / platform-outputs.agnes-ai.space)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
经用户实测走香港代理 103.242.14.208:11495 延迟更低、更稳定,故改为走 HK 代理
|
经用户实测走香港代理 103.242.14.208:11495 延迟更低、更稳定,故改为走 HK 代理
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(带鉴权),不再直连,也不经智能路由代理(172.18.0.1:7890 对 AGNES 返 502)。
|
(带鉴权),不再直连,也不经智能路由代理(172.18.0.1:7890 对 AGNES 返 502)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
其余国内/平台域名直连,真正的境外域名走 _outbound_proxy()。
|
其余国内/平台域名直连,真正的境外域名走 _outbound_proxy()。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
注意:httpx 默认 trust_env=True 会忽略 proxy 参数而改用环境变量 HTTP_PROXY,
|
注意:httpx 默认 trust_env=True 会忽略 proxy 参数而改用环境变量 HTTP_PROXY,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
故调用方必须同时传 trust_env=False 才能真正走此处指定的代理。
|
故调用方必须同时传 trust_env=False 才能真正走此处指定的代理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
host = (urlparse(url).hostname or "").lower()
|
host = (urlparse(url).hostname or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 文生图 / 视频生成走直连:智能路由代理(172.18.0.1:7890)与 HK 代理对这类长耗时、
|
# 文生图 / 视频生成走直连:智能路由代理(172.18.0.1:7890)与 HK 代理对这类长耗时、
|
||||||
# 大响应体的生成连接会偶发重置(Server disconnected),而各图像/视频供应商均为公网
|
# 大响应体的生成连接会偶发重置(Server disconnected),而各图像/视频供应商均为公网
|
||||||
# API,直连更稳定(已实测 gpt-image-2 / agnes 直连均返回 200)。故媒体端点统一直连。
|
# API,直连更稳定(已实测 gpt-image-2 / agnes 直连均返回 200)。故媒体端点统一直连。
|
||||||
_lower = (url or "").lower()
|
_lower = (url or "").lower()
|
||||||
if "/images/" in _lower or "/videos/" in _lower or "/v1/images" in _lower or "/v1/videos" in _lower:
|
if "/images/" in _lower or "/videos/" in _lower or "/v1/images" in _lower or "/v1/videos" in _lower:
|
||||||
return None
|
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:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _HK_PROXY
|
return _HK_PROXY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。
|
# 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if "qihuyun.com" in host:
|
if "qihuyun.com" in host:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 尊重 NO_PROXY:国内域名(硅基流动/DeepSeek/小米MoE 等)直连更稳定,
|
# 尊重 NO_PROXY:国内域名(硅基流动/DeepSeek/小米MoE 等)直连更稳定,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 避免经易波动的智能路由代理 172.18.0.1:7890(对国内域名偶发连接重置)。
|
# 避免经易波动的智能路由代理 172.18.0.1:7890(对国内域名偶发连接重置)。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for _np in _no_proxy:
|
for _np in _no_proxy:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if host == _np or host.endswith("." + _np) or _np in host:
|
if host == _np or host.endswith("." + _np) or _np in host:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _outbound_proxy()
|
return _outbound_proxy()
|
||||||
|
|
||||||
def _extract_text_from_json(obj) -> str:
|
def _extract_text_from_json(obj) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"""从 n8n / 通用 webhook 的任意 JSON 响应中尽力抽取文本回答。"""
|
"""从 n8n / 通用 webhook 的任意 JSON 响应中尽力抽取文本回答。"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if isinstance(obj, (int, float, bool)):
|
if isinstance(obj, (int, float, bool)):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if isinstance(obj, list):
|
if isinstance(obj, list):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 取第一个可抽取到文本的元素
|
# 取第一个可抽取到文本的元素
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for it in obj:
|
for it in obj:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
t = _extract_text_from_json(it)
|
t = _extract_text_from_json(it)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if t:
|
if t:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 常见回答字段优先级
|
# 常见回答字段优先级
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
for k in ("output", "text", "answer", "response", "reply",
|
for k in ("output", "text", "answer", "response", "reply",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"message", "content", "result", "data"):
|
"message", "content", "result", "data"):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if k in obj:
|
if k in obj:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
t = _extract_text_from_json(obj[k])
|
t = _extract_text_from_json(obj[k])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if t:
|
if t:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 兜底:整体序列化
|
# 兜底:整体序列化
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return json.dumps(obj, ensure_ascii=False)
|
return json.dumps(obj, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
class CreateConversationRequest(BaseModel):
|
class CreateConversationRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
title: str = Field(default="新对话")
|
title: str = Field(default="新对话")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model_id: Optional[str] = None
|
model_id: Optional[str] = None
|
||||||
|
|
||||||
class UpdateConversationRequest(BaseModel):
|
class UpdateConversationRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status: Optional[str] = None # active, archived
|
status: Optional[str] = None # active, archived
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pinned: Optional[bool] = None
|
pinned: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
agent_ids: Optional[list] = None
|
agent_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
skill_ids: Optional[list] = None
|
skill_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
knowledge_base_ids: Optional[list] = None
|
knowledge_base_ids: Optional[list] = None
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
model_id: Optional[str] = None
|
model_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
skill_ids: Optional[list] = None
|
skill_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
agent_ids: Optional[list] = None
|
agent_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
knowledge_base_ids: Optional[list] = None
|
knowledge_base_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
mcp_ids: Optional[list] = None
|
mcp_ids: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
attachments: Optional[list] = None
|
attachments: Optional[list] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
use_tools: Optional[bool] = False
|
use_tools: Optional[bool] = False
|
||||||
@@ -691,7 +691,12 @@ def list_apps(
|
|||||||
"capability": app.capability or None,
|
"capability": app.capability or None,
|
||||||
"category": app.category or None,
|
"category": app.category or None,
|
||||||
"tags": app.tags or None,
|
"tags": app.tags or None,
|
||||||
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
|
# 官方导入并发布的技能默认全局已安装(见 #716:无论前台/后台导入,默认全局安装)
|
||||||
|
"installed": (
|
||||||
|
app.id in installed_app_ids
|
||||||
|
or is_user_owned
|
||||||
|
or (app.source == "official" and app.status == "published")
|
||||||
|
),
|
||||||
"version": app.version or "1.0.0",
|
"version": app.version or "1.0.0",
|
||||||
"security_scan": app.security_scan,
|
"security_scan": app.security_scan,
|
||||||
"verified": bool(app.verified),
|
"verified": bool(app.verified),
|
||||||
@@ -756,7 +761,8 @@ def get_app(
|
|||||||
"tags": app.tags or None,
|
"tags": app.tags or None,
|
||||||
"created_at": str(app.created_at),
|
"created_at": str(app.created_at),
|
||||||
"purchased": purchased,
|
"purchased": purchased,
|
||||||
"installed": installed,
|
# 官方导入并发布的技能默认全局已安装(见 #716)
|
||||||
|
"installed": installed or (app.source == "official" and app.status == "published"),
|
||||||
"version": app.version or "1.0.0",
|
"version": app.version or "1.0.0",
|
||||||
"security_scan": app.security_scan,
|
"security_scan": app.security_scan,
|
||||||
"verified": bool(app.verified),
|
"verified": bool(app.verified),
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""服务端 URL 读取器(Issue 2 修复)
|
||||||
|
|
||||||
|
当用户输入包含 URL 且明确要求『读取 / 加入 / 安装 / 分析 / 总结』等意图时,
|
||||||
|
服务端抓取该链接的真实内容并作为参考上下文注入对话,使模型能基于页面真实信息
|
||||||
|
作答(例如按 GitHub 仓库 README 的 Quick Start 引导用户完成安装/加入),
|
||||||
|
而不是凭空编造。
|
||||||
|
|
||||||
|
SSRF 防护:仅允许 http/https;解析域名 IP 后阻断内网/回环/链路本地/云元数据地址;
|
||||||
|
限制跳转、响应体大小与超时。
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_URL_RE = re.compile(r'https?://[^\s"\)\\\]>]+', re.IGNORECASE)
|
||||||
|
# 触发意图:显式要求读取/加入/安装/分析/总结/打开/访问 等
|
||||||
|
_INTENT_RE = re.compile(
|
||||||
|
r'(read|join|install|fetch|summar|analys|open|visit|access|browse|'
|
||||||
|
r'读取|加入|安装|打开|访问|浏览|总结|分析|抓取|看下这个|查看该|这个网页|这个页面|这个链接|help me join)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 常见私有/保留网段(阻断内网扫描与云元数据)
|
||||||
|
_BLOCKED_NETS = [
|
||||||
|
ipaddress.ip_network('10.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('172.16.0.0/12'),
|
||||||
|
ipaddress.ip_network('192.168.0.0/16'),
|
||||||
|
ipaddress.ip_network('127.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('169.254.0.0/16'),
|
||||||
|
ipaddress.ip_network('::1/128'),
|
||||||
|
ipaddress.ip_network('fc00::/7'),
|
||||||
|
ipaddress.ip_network('fe80::/10'),
|
||||||
|
ipaddress.ip_network('0.0.0.0/8'),
|
||||||
|
]
|
||||||
|
|
||||||
|
_MAX_CHARS = 16000
|
||||||
|
_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_host(host: str) -> bool:
|
||||||
|
host = (host or '').strip().lower()
|
||||||
|
if not host:
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r'(\d{1,3}\.){3}\d{1,3}', host):
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
return any(ip in net for net in _BLOCKED_NETS)
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, None)
|
||||||
|
for info in infos:
|
||||||
|
ipstr = info[4][0]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ipstr)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if any(ip in net for net in _BLOCKED_NETS):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_client():
|
||||||
|
try:
|
||||||
|
return httpx.AsyncClient(trust_env=True, follow_redirects=True, timeout=_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
return httpx.AsyncClient(trust_env=False, follow_redirects=True, timeout=_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
async def _github_text(url: str):
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.netloc.lower() not in ('github.com', 'www.github.com'):
|
||||||
|
return None
|
||||||
|
parts = [x for x in p.path.split('/') if x]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
owner, repo = parts[0], parts[1]
|
||||||
|
if len(parts) >= 4 and parts[2] == 'blob':
|
||||||
|
ref = parts[3]
|
||||||
|
fpath = '/'.join(parts[4:])
|
||||||
|
raw = f'https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{fpath}'
|
||||||
|
return await _fetch_text(raw)
|
||||||
|
api = f'https://api.github.com/repos/{owner}/{repo}/readme'
|
||||||
|
try:
|
||||||
|
async with await _make_client() as c:
|
||||||
|
r = await c.get(api, headers={
|
||||||
|
'Accept': 'application/vnd.github+json',
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
})
|
||||||
|
if r.status_code == 200:
|
||||||
|
import base64
|
||||||
|
d = r.json()
|
||||||
|
return base64.b64decode(d.get('content', '')).decode('utf-8', 'replace')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('github readme fetch failed: %s', e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_text(url: str) -> str:
|
||||||
|
async with await _make_client() as c:
|
||||||
|
r = await c.get(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.content[:2_000_000]
|
||||||
|
ctype = r.headers.get('content-type', '')
|
||||||
|
text = data.decode('utf-8', 'replace')
|
||||||
|
if 'html' in ctype:
|
||||||
|
return _html_to_text(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_text(html: str) -> str:
|
||||||
|
html = re.sub(r'(?is)<(script|style|head|noscript|svg|template)[^>]*>.*?</\1>', ' ', html)
|
||||||
|
html = re.sub(r'(?is)<!--.*?-->', ' ', html)
|
||||||
|
html = re.sub(r'(?is)<[^>]+>', ' ', html)
|
||||||
|
import html as _html
|
||||||
|
text = _html.unescape(html)
|
||||||
|
text = re.sub(r'[ \t]+', ' ', text)
|
||||||
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_url_context(text: str):
|
||||||
|
"""若有 URL 且命中读取意图,返回 {'url','text'};否则返回 None。"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
m = _URL_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
url = m.group(0).rstrip('.,);]')
|
||||||
|
if not _INTENT_RE.search(text):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
if p.scheme not in ('http', 'https'):
|
||||||
|
return None
|
||||||
|
if _is_blocked_host(p.hostname):
|
||||||
|
logger.warning('blocked host for url fetch: %s', p.hostname)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if 'github.com' in (p.hostname or '').lower():
|
||||||
|
body = await _github_text(url)
|
||||||
|
if not body:
|
||||||
|
body = await _fetch_text(url)
|
||||||
|
else:
|
||||||
|
body = await _fetch_text(url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('url fetch failed: %s', e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not body or not body.strip():
|
||||||
|
return None
|
||||||
|
return {'url': url, 'text': body[:_MAX_CHARS]}
|
||||||
@@ -1,395 +1,395 @@
|
|||||||
"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
|
"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
|
||||||
|
|
||||||
设计目标:
|
设计目标:
|
||||||
- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
|
- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
|
||||||
- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
|
- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
|
||||||
- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
|
- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
|
||||||
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
|
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
|
||||||
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
|
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
|
||||||
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
|
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger("aiflow.orchestrator")
|
logger = logging.getLogger("aiflow.orchestrator")
|
||||||
|
|
||||||
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
|
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
|
||||||
|
|
||||||
|
|
||||||
class OrchestrationError(ValueError):
|
class OrchestrationError(ValueError):
|
||||||
"""图结构非法或执行被 halt。"""
|
"""图结构非法或执行被 halt。"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 校验 + 拓扑
|
# 校验 + 拓扑
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def validate_graph(graph: Any) -> None:
|
def validate_graph(graph: Any) -> None:
|
||||||
if not isinstance(graph, dict):
|
if not isinstance(graph, dict):
|
||||||
raise OrchestrationError("orchestration 必须为对象")
|
raise OrchestrationError("orchestration 必须为对象")
|
||||||
nodes = graph.get("nodes")
|
nodes = graph.get("nodes")
|
||||||
edges = graph.get("edges")
|
edges = graph.get("edges")
|
||||||
if not isinstance(nodes, list) or not nodes:
|
if not isinstance(nodes, list) or not nodes:
|
||||||
raise OrchestrationError("nodes 必须为非空数组")
|
raise OrchestrationError("nodes 必须为非空数组")
|
||||||
if not isinstance(edges, list):
|
if not isinstance(edges, list):
|
||||||
raise OrchestrationError("edges 必须为数组")
|
raise OrchestrationError("edges 必须为数组")
|
||||||
|
|
||||||
ids = set()
|
ids = set()
|
||||||
for i, n in enumerate(nodes):
|
for i, n in enumerate(nodes):
|
||||||
if not isinstance(n, dict):
|
if not isinstance(n, dict):
|
||||||
raise OrchestrationError(f"节点 #{i} 必须为对象")
|
raise OrchestrationError(f"节点 #{i} 必须为对象")
|
||||||
nid = n.get("id")
|
nid = n.get("id")
|
||||||
if not nid or not isinstance(nid, str):
|
if not nid or not isinstance(nid, str):
|
||||||
raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
|
raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
|
||||||
if nid in ids:
|
if nid in ids:
|
||||||
raise OrchestrationError(f"节点 id 重复: {nid}")
|
raise OrchestrationError(f"节点 id 重复: {nid}")
|
||||||
ids.add(nid)
|
ids.add(nid)
|
||||||
ntype = n.get("type")
|
ntype = n.get("type")
|
||||||
if ntype not in VALID_NODE_TYPES:
|
if ntype not in VALID_NODE_TYPES:
|
||||||
raise OrchestrationError(
|
raise OrchestrationError(
|
||||||
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
|
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
|
||||||
)
|
)
|
||||||
if ntype in ("local", "external") and not n.get("agent_id"):
|
if ntype in ("local", "external") and not n.get("agent_id"):
|
||||||
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
||||||
|
|
||||||
for i, e in enumerate(edges):
|
for i, e in enumerate(edges):
|
||||||
if not isinstance(e, dict):
|
if not isinstance(e, dict):
|
||||||
raise OrchestrationError(f"边 #{i} 必须为对象")
|
raise OrchestrationError(f"边 #{i} 必须为对象")
|
||||||
f = e.get("from")
|
f = e.get("from")
|
||||||
t = e.get("to")
|
t = e.get("to")
|
||||||
if f not in ids:
|
if f not in ids:
|
||||||
raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
|
raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
|
||||||
if t not in ids:
|
if t not in ids:
|
||||||
raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
|
raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
|
||||||
if f == t:
|
if f == t:
|
||||||
raise OrchestrationError(f"边 #{i} 存在自环: {f}")
|
raise OrchestrationError(f"边 #{i} 存在自环: {f}")
|
||||||
|
|
||||||
|
|
||||||
def topological_layers(graph: dict) -> list:
|
def topological_layers(graph: dict) -> list:
|
||||||
"""Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
|
"""Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
|
||||||
nodes = graph["nodes"]
|
nodes = graph["nodes"]
|
||||||
edges = graph.get("edges", [])
|
edges = graph.get("edges", [])
|
||||||
indeg = {n["id"]: 0 for n in nodes}
|
indeg = {n["id"]: 0 for n in nodes}
|
||||||
adj = {n["id"]: [] for n in nodes}
|
adj = {n["id"]: [] for n in nodes}
|
||||||
for e in edges:
|
for e in edges:
|
||||||
f, t = e["from"], e["to"]
|
f, t = e["from"], e["to"]
|
||||||
adj[f].append(t)
|
adj[f].append(t)
|
||||||
indeg[t] += 1
|
indeg[t] += 1
|
||||||
order = {n["id"]: i for i, n in enumerate(nodes)}
|
order = {n["id"]: i for i, n in enumerate(nodes)}
|
||||||
layers: list = []
|
layers: list = []
|
||||||
remaining = set(indeg)
|
remaining = set(indeg)
|
||||||
while remaining:
|
while remaining:
|
||||||
layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
|
layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
|
||||||
if not layer:
|
if not layer:
|
||||||
cycle = sorted(remaining, key=lambda x: order[x])
|
cycle = sorted(remaining, key=lambda x: order[x])
|
||||||
raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
|
raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
|
||||||
layers.append(layer)
|
layers.append(layer)
|
||||||
for nid in layer:
|
for nid in layer:
|
||||||
remaining.discard(nid)
|
remaining.discard(nid)
|
||||||
for nxt in adj[nid]:
|
for nxt in adj[nid]:
|
||||||
indeg[nxt] -= 1
|
indeg[nxt] -= 1
|
||||||
return layers
|
return layers
|
||||||
|
|
||||||
|
|
||||||
def _node_by_id(graph: dict, nid: str) -> dict:
|
def _node_by_id(graph: dict, nid: str) -> dict:
|
||||||
for n in graph["nodes"]:
|
for n in graph["nodes"]:
|
||||||
if n["id"] == nid:
|
if n["id"] == nid:
|
||||||
return n
|
return n
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _predecessors(graph: dict, nid: str) -> list:
|
def _predecessors(graph: dict, nid: str) -> list:
|
||||||
return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
|
return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
|
||||||
|
|
||||||
|
|
||||||
def _successors(graph: dict, nid: str) -> list:
|
def _successors(graph: dict, nid: str) -> list:
|
||||||
return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
|
return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
|
||||||
|
|
||||||
|
|
||||||
def _find_output_node(graph: dict) -> str:
|
def _find_output_node(graph: dict) -> str:
|
||||||
if graph.get("output_node"):
|
if graph.get("output_node"):
|
||||||
return graph["output_node"]
|
return graph["output_node"]
|
||||||
sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
|
sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
|
||||||
if len(sinks) == 1:
|
if len(sinks) == 1:
|
||||||
return sinks[0]
|
return sinks[0]
|
||||||
for s in sinks:
|
for s in sinks:
|
||||||
if _node_by_id(graph, s).get("type") == "reduce":
|
if _node_by_id(graph, s).get("type") == "reduce":
|
||||||
return s
|
return s
|
||||||
if sinks:
|
if sinks:
|
||||||
return sinks[0]
|
return sinks[0]
|
||||||
return graph["nodes"][-1]["id"]
|
return graph["nodes"][-1]["id"]
|
||||||
|
|
||||||
|
|
||||||
def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
|
def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
|
||||||
"""入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
|
"""入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
|
||||||
if is_entry:
|
if is_entry:
|
||||||
return query or ""
|
return query or ""
|
||||||
if upstream:
|
if upstream:
|
||||||
return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
|
return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
|
||||||
return query or ""
|
return query or ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 模型解析 + LLM 调用原语
|
# 模型解析 + LLM 调用原语
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _resolve_model_and_endpoint(model_id, db):
|
async def _resolve_model_and_endpoint(model_id, db):
|
||||||
from app.models import AIModel, ModelProvider
|
from app.models import AIModel, ModelProvider
|
||||||
from ..capability_registry import resolve_auto_model
|
from ..capability_registry import resolve_auto_model
|
||||||
|
|
||||||
ai_model = None
|
ai_model = None
|
||||||
if model_id and model_id != "auto":
|
if model_id and model_id != "auto":
|
||||||
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
||||||
if not ai_model:
|
if not ai_model:
|
||||||
ai_model = resolve_auto_model("text", db)
|
ai_model = resolve_auto_model("text", db)
|
||||||
if not ai_model:
|
if not ai_model:
|
||||||
raise OrchestrationError("无法解析到可用模型")
|
raise OrchestrationError("无法解析到可用模型")
|
||||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
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_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||||
api_url = ai_model.endpoint
|
api_url = ai_model.endpoint
|
||||||
if not api_url and provider and provider.api_base_url:
|
if not api_url and provider and provider.api_base_url:
|
||||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||||
if not api_url or not api_key:
|
if not api_url or not api_key:
|
||||||
raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
|
raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
|
||||||
return ai_model, api_url, api_key
|
return ai_model, api_url, api_key
|
||||||
|
|
||||||
|
|
||||||
async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
|
async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
|
||||||
from app.api.chat_utils import _proxy_for_url
|
from app.api.chat_utils import _proxy_for_url
|
||||||
from app.core.observability import span
|
from app.core.observability import span
|
||||||
|
|
||||||
ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
|
ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_content},
|
{"role": "user", "content": user_content},
|
||||||
]
|
]
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||||
async with span("orchestrator.llm", model=ai_model.id):
|
async with span("orchestrator.llm", model=ai_model.id):
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
trust_env=False,
|
trust_env=False,
|
||||||
) as client:
|
) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
api_url,
|
api_url,
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
json=request_body,
|
json=request_body,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
return data["choices"][0]["message"].get("content") or ""
|
return data["choices"][0]["message"].get("content") or ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
|
# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
|
async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
|
||||||
from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
|
from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
|
||||||
from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
|
from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
|
||||||
from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
|
from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
|
||||||
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],
|
||||||
kb_context=kb_context,
|
kb_context=kb_context,
|
||||||
user_content=node_input,
|
user_content=node_input,
|
||||||
auto_discovered=False,
|
auto_discovered=False,
|
||||||
)
|
)
|
||||||
ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
|
ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
|
||||||
tool_policy = _resolve_agent_tool_policy([agent])
|
tool_policy = _resolve_agent_tool_policy([agent])
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": node_input},
|
{"role": "user", "content": node_input},
|
||||||
]
|
]
|
||||||
called_skills: list = []
|
called_skills: list = []
|
||||||
tool_call_count = 0
|
tool_call_count = 0
|
||||||
max_tool_rounds = 5
|
max_tool_rounds = 5
|
||||||
last_content = ""
|
last_content = ""
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
trust_env=False,
|
trust_env=False,
|
||||||
) as client:
|
) as client:
|
||||||
for round_num in range(max_tool_rounds):
|
for round_num in range(max_tool_rounds):
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||||
if function_definitions:
|
if function_definitions:
|
||||||
request_body["tools"] = [
|
request_body["tools"] = [
|
||||||
{"type": "function", "function": fd} for fd in function_definitions
|
{"type": "function", "function": fd} for fd in function_definitions
|
||||||
]
|
]
|
||||||
request_body["tool_choice"] = "auto"
|
request_body["tool_choice"] = "auto"
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
api_url,
|
api_url,
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
json=request_body,
|
json=request_body,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
message = data["choices"][0]["message"]
|
message = data["choices"][0]["message"]
|
||||||
tool_calls = message.get("tool_calls") or []
|
tool_calls = message.get("tool_calls") or []
|
||||||
content = message.get("content") or ""
|
content = message.get("content") or ""
|
||||||
last_content = content
|
last_content = content
|
||||||
if not tool_calls or not function_definitions:
|
if not tool_calls or not function_definitions:
|
||||||
return {"text": content, "used_tools": called_skills}
|
return {"text": content, "used_tools": called_skills}
|
||||||
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
|
messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
|
||||||
round_tool_count = 0
|
round_tool_count = 0
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
func_name = tc.get("function", {}).get("name", "")
|
func_name = tc.get("function", {}).get("name", "")
|
||||||
check = _check_tool_policy(
|
check = _check_tool_policy(
|
||||||
func_name, None, skills, tool_policy, tool_call_count, round_tool_count
|
func_name, None, skills, tool_policy, tool_call_count, round_tool_count
|
||||||
)
|
)
|
||||||
if not check["allowed"]:
|
if not check["allowed"]:
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
||||||
"content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
|
"content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
|
||||||
})
|
})
|
||||||
tool_call_count += 1
|
tool_call_count += 1
|
||||||
round_tool_count += 1
|
round_tool_count += 1
|
||||||
continue
|
continue
|
||||||
tool_call_count += 1
|
tool_call_count += 1
|
||||||
round_tool_count += 1
|
round_tool_count += 1
|
||||||
try:
|
try:
|
||||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
|
func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
|
||||||
except Exception:
|
except Exception:
|
||||||
func_args = {}
|
func_args = {}
|
||||||
res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
|
res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
|
||||||
called_skills.append(func_name)
|
called_skills.append(func_name)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
"tool_call_id": tc.get("id", f"call_{round_num}"),
|
||||||
"content": res,
|
"content": res,
|
||||||
})
|
})
|
||||||
return {"text": last_content, "used_tools": called_skills}
|
return {"text": last_content, "used_tools": called_skills}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 单节点执行(含独立 DB 会话,保证并发安全)
|
# 单节点执行(含独立 DB 会话,保证并发安全)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
|
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
|
||||||
from app.api.chat_skill import _resolve_market_agents
|
from app.api.chat_skill import _resolve_market_agents
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
|
|
||||||
nid = node["id"]
|
nid = node["id"]
|
||||||
ntype = node["type"]
|
ntype = node["type"]
|
||||||
preds = _predecessors(graph, nid)
|
preds = _predecessors(graph, nid)
|
||||||
pred_results = [blackboard[p] for p in preds]
|
pred_results = [blackboard[p] for p in preds]
|
||||||
is_entry = not preds
|
is_entry = not preds
|
||||||
|
|
||||||
if pred_results:
|
if pred_results:
|
||||||
parts = []
|
parts = []
|
||||||
for b in pred_results:
|
for b in pred_results:
|
||||||
label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
|
label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
|
||||||
if b.get("ok"):
|
if b.get("ok"):
|
||||||
parts.append(f"【{label}】\n{b.get('output', '')}")
|
parts.append(f"【{label}】\n{b.get('output', '')}")
|
||||||
else:
|
else:
|
||||||
parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
|
parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
|
||||||
upstream = "\n\n".join(parts)
|
upstream = "\n\n".join(parts)
|
||||||
else:
|
else:
|
||||||
upstream = ""
|
upstream = ""
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
if ntype in ("llm", "reduce"):
|
if ntype in ("llm", "reduce"):
|
||||||
if ntype == "llm":
|
if ntype == "llm":
|
||||||
sys_prompt = node.get("system_prompt") or (
|
sys_prompt = node.get("system_prompt") or (
|
||||||
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
|
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
|
||||||
"只输出处理后的结果,不要多余解释。"
|
"只输出处理后的结果,不要多余解释。"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sys_prompt = node.get("system_prompt") or (
|
sys_prompt = node.get("system_prompt") or (
|
||||||
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
|
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
|
||||||
)
|
)
|
||||||
uc = _assemble_node_input(query, upstream, is_entry)
|
uc = _assemble_node_input(query, upstream, is_entry)
|
||||||
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
|
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
|
||||||
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
|
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
|
||||||
|
|
||||||
if ntype == "local":
|
if ntype == "local":
|
||||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||||
if not agents:
|
if not agents:
|
||||||
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||||
ni = _assemble_node_input(query, upstream, is_entry)
|
ni = _assemble_node_input(query, upstream, is_entry)
|
||||||
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
|
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"node_id": nid,
|
"node_id": nid,
|
||||||
"node_type": "local",
|
"node_type": "local",
|
||||||
"output": res["text"],
|
"output": res["text"],
|
||||||
"used_tools": res.get("used_tools"),
|
"used_tools": res.get("used_tools"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ntype == "external":
|
if ntype == "external":
|
||||||
from app.api.chat_agents import _call_external_agent
|
from app.api.chat_agents import _call_external_agent
|
||||||
|
|
||||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||||
if not agents:
|
if not agents:
|
||||||
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||||
ni = _assemble_node_input(query, upstream, is_entry)
|
ni = _assemble_node_input(query, upstream, is_entry)
|
||||||
answer, _ = await _call_external_agent(
|
answer, _ = await _call_external_agent(
|
||||||
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
|
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
|
||||||
)
|
)
|
||||||
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
|
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
|
||||||
|
|
||||||
raise OrchestrationError(f"未知节点类型 {ntype}")
|
raise OrchestrationError(f"未知节点类型 {ntype}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 入口
|
# 入口
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
||||||
"""执行编排图,返回 (answer: str, trace: dict)。
|
"""执行编排图,返回 (answer: str, trace: dict)。
|
||||||
|
|
||||||
- 拓扑分层后逐层 asyncio.gather 并行执行。
|
- 拓扑分层后逐层 asyncio.gather 并行执行。
|
||||||
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
|
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
|
||||||
"""
|
"""
|
||||||
validate_graph(graph)
|
validate_graph(graph)
|
||||||
layers = topological_layers(graph)
|
layers = topological_layers(graph)
|
||||||
blackboard: dict = {}
|
blackboard: dict = {}
|
||||||
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
|
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
|
||||||
|
|
||||||
for layer in layers:
|
for layer in layers:
|
||||||
tasks = [
|
tasks = [
|
||||||
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
|
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
|
||||||
for nid in layer
|
for nid in layer
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
for nid, res in zip(layer, results):
|
for nid, res in zip(layer, results):
|
||||||
node = _node_by_id(graph, nid)
|
node = _node_by_id(graph, nid)
|
||||||
if isinstance(res, Exception):
|
if isinstance(res, Exception):
|
||||||
on_error = node.get("on_error", "continue")
|
on_error = node.get("on_error", "continue")
|
||||||
err_msg = str(res)
|
err_msg = str(res)
|
||||||
blackboard[nid] = {
|
blackboard[nid] = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"node_id": nid,
|
"node_id": nid,
|
||||||
"node_type": node["type"],
|
"node_type": node["type"],
|
||||||
"error": err_msg,
|
"error": err_msg,
|
||||||
}
|
}
|
||||||
trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
|
trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
|
||||||
logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
|
logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
|
||||||
if on_error == "halt":
|
if on_error == "halt":
|
||||||
raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
|
raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
|
||||||
else:
|
else:
|
||||||
blackboard[nid] = res
|
blackboard[nid] = res
|
||||||
trace["nodes"].append({
|
trace["nodes"].append({
|
||||||
"id": nid,
|
"id": nid,
|
||||||
"type": res.get("node_type"),
|
"type": res.get("node_type"),
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"used_tools": res.get("used_tools"),
|
"used_tools": res.get("used_tools"),
|
||||||
})
|
})
|
||||||
|
|
||||||
output_node = _find_output_node(graph)
|
output_node = _find_output_node(graph)
|
||||||
final = blackboard.get(output_node, {})
|
final = blackboard.get(output_node, {})
|
||||||
if final.get("ok"):
|
if final.get("ok"):
|
||||||
answer = final.get("output", "")
|
answer = final.get("output", "")
|
||||||
else:
|
else:
|
||||||
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
|
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
|
||||||
trace["output_node"] = output_node
|
trace["output_node"] = output_node
|
||||||
return answer, trace
|
return answer, trace
|
||||||
@@ -1,360 +1,390 @@
|
|||||||
"""
|
"""
|
||||||
PGVector 向量检索模块
|
PGVector 向量检索模块
|
||||||
====================
|
====================
|
||||||
使用 PostgreSQL + pgvector 实现语义向量检索,
|
使用 PostgreSQL + pgvector 实现语义向量检索,
|
||||||
与现有 MySQL BM25 检索配合,实现混合检索。
|
与现有 MySQL BM25 检索配合,实现混合检索。
|
||||||
|
|
||||||
使用方式:
|
使用方式:
|
||||||
from app.pgvector_search import vector_search
|
from app.pgvector_search import vector_search
|
||||||
results = await vector_search(query, kb_id, top_k=5)
|
results = await vector_search(query, kb_id, top_k=5)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime
|
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")
|
||||||
PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
|
PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
|
||||||
|
|
||||||
# 嵌入模型配置
|
# 嵌入模型配置
|
||||||
EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
|
EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
|
||||||
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 额度"的隐性成本障碍 ----
|
||||||
_pg_pool = None
|
import sqlite3, hashlib, json as _json
|
||||||
_pool_lock = asyncio.Lock()
|
_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
|
||||||
|
def _embed_cache_get(text, model):
|
||||||
|
try:
|
||||||
async def get_pg_pool():
|
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||||
"""获取 PostgreSQL 异步连接池"""
|
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||||
global _pg_pool
|
row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
|
||||||
if _pg_pool is not None:
|
conn.close()
|
||||||
return _pg_pool
|
if row:
|
||||||
|
return _json.loads(row[0])
|
||||||
async with _pool_lock:
|
except Exception:
|
||||||
if _pg_pool is not None:
|
return None
|
||||||
return _pg_pool
|
return None
|
||||||
|
def _embed_cache_put(text, model, vec):
|
||||||
try:
|
try:
|
||||||
import asyncpg
|
key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
|
||||||
except ImportError:
|
conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
|
||||||
logger.warning("asyncpg not installed, vector search disabled")
|
conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||||
return None
|
conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
|
||||||
|
conn.commit()
|
||||||
try:
|
conn.close()
|
||||||
_pg_pool = await asyncpg.create_pool(
|
except Exception:
|
||||||
host=PG_HOST,
|
pass
|
||||||
port=PG_PORT,
|
|
||||||
user=PG_USER,
|
|
||||||
password=PG_PASSWORD,
|
# 连接池
|
||||||
database=PG_DB,
|
_pg_pool = None
|
||||||
min_size=2,
|
_pool_lock = asyncio.Lock()
|
||||||
max_size=10,
|
|
||||||
command_timeout=30,
|
|
||||||
)
|
async def get_pg_pool():
|
||||||
logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
|
"""获取 PostgreSQL 异步连接池"""
|
||||||
|
global _pg_pool
|
||||||
# 初始化表结构
|
if _pg_pool is not None:
|
||||||
async with _pg_pool.acquire() as conn:
|
return _pg_pool
|
||||||
await conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS document_embeddings (
|
async with _pool_lock:
|
||||||
id SERIAL PRIMARY KEY,
|
if _pg_pool is not None:
|
||||||
doc_id VARCHAR(64) NOT NULL,
|
return _pg_pool
|
||||||
kb_id VARCHAR(64) NOT NULL,
|
|
||||||
chunk_id VARCHAR(64),
|
try:
|
||||||
content TEXT NOT NULL,
|
import asyncpg
|
||||||
embedding vector(1024),
|
except ImportError:
|
||||||
metadata JSONB DEFAULT '{}',
|
logger.warning("asyncpg not installed, vector search disabled")
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
return None
|
||||||
);
|
|
||||||
""")
|
try:
|
||||||
await conn.execute("""
|
_pg_pool = await asyncpg.create_pool(
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_kb
|
host=PG_HOST,
|
||||||
ON document_embeddings(kb_id);
|
port=PG_PORT,
|
||||||
""")
|
user=PG_USER,
|
||||||
await conn.execute("""
|
password=PG_PASSWORD,
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_doc
|
database=PG_DB,
|
||||||
ON document_embeddings(doc_id);
|
min_size=2,
|
||||||
""")
|
max_size=10,
|
||||||
await conn.execute("""
|
command_timeout=30,
|
||||||
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
)
|
||||||
ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
|
logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
|
||||||
WITH (lists = 100);
|
|
||||||
""")
|
# 初始化表结构
|
||||||
logger.info("PGVector tables initialized")
|
async with _pg_pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
return _pg_pool
|
CREATE TABLE IF NOT EXISTS document_embeddings (
|
||||||
except Exception as e:
|
id SERIAL PRIMARY KEY,
|
||||||
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
doc_id VARCHAR(64) NOT NULL,
|
||||||
_pg_pool = None
|
kb_id VARCHAR(64) NOT NULL,
|
||||||
return None
|
chunk_id VARCHAR(64),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding vector(1024),
|
||||||
async def get_embedding(text: str) -> Optional[List[float]]:
|
metadata JSONB DEFAULT '{}',
|
||||||
"""调用嵌入API获取文本向量"""
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
if not EMBEDDING_API_KEY:
|
);
|
||||||
logger.warning("No embedding API key configured")
|
""")
|
||||||
return None
|
await conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_kb
|
||||||
try:
|
ON document_embeddings(kb_id);
|
||||||
import httpx
|
""")
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
await conn.execute("""
|
||||||
resp = await client.post(
|
CREATE INDEX IF NOT EXISTS idx_embeddings_doc
|
||||||
f"{EMBEDDING_API_URL}/v1/embeddings",
|
ON document_embeddings(doc_id);
|
||||||
headers={
|
""")
|
||||||
"Authorization": f"Bearer {EMBEDDING_API_KEY}",
|
await conn.execute("""
|
||||||
"Content-Type": "application/json",
|
CREATE INDEX IF NOT EXISTS idx_embeddings_vector
|
||||||
},
|
ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
|
||||||
json={
|
WITH (lists = 100);
|
||||||
"model": EMBEDDING_MODEL,
|
""")
|
||||||
"input": text[:8000], # 截断防止超长
|
logger.info("PGVector tables initialized")
|
||||||
},
|
|
||||||
)
|
return _pg_pool
|
||||||
if resp.status_code == 200:
|
except Exception as e:
|
||||||
data = resp.json()
|
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
||||||
embedding = data["data"][0]["embedding"]
|
_pg_pool = None
|
||||||
return embedding
|
return None
|
||||||
else:
|
|
||||||
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
|
|
||||||
return None
|
async def get_embedding(text: str) -> Optional[List[float]]:
|
||||||
except Exception as e:
|
"""调用嵌入API获取文本向量"""
|
||||||
logger.error(f"Embedding generation failed: {e}")
|
if not EMBEDDING_API_KEY:
|
||||||
return None
|
logger.warning("No embedding API key configured")
|
||||||
|
return None
|
||||||
|
|
||||||
async def vector_search(
|
cached = _embed_cache_get(text, EMBEDDING_MODEL)
|
||||||
query: str,
|
if cached:
|
||||||
kb_id: Optional[str] = None,
|
return cached
|
||||||
top_k: int = 5,
|
try:
|
||||||
threshold: float = 0.5,
|
import httpx
|
||||||
) -> List[Dict]:
|
async with httpx.AsyncClient(timeout=30, trust_env=False) as client:
|
||||||
"""
|
resp = await client.post(
|
||||||
向量语义检索
|
f"{EMBEDDING_API_URL}/v1/embeddings",
|
||||||
|
headers={
|
||||||
Args:
|
"Authorization": f"Bearer {EMBEDDING_API_KEY}",
|
||||||
query: 查询文本
|
"Content-Type": "application/json",
|
||||||
kb_id: 知识库ID (可选,不指定则搜索全部)
|
},
|
||||||
top_k: 返回结果数
|
json={
|
||||||
threshold: 相似度阈值 (0-1)
|
"model": EMBEDDING_MODEL,
|
||||||
|
"input": text[:8000], # 截断防止超长
|
||||||
Returns:
|
},
|
||||||
[{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
|
)
|
||||||
"""
|
if resp.status_code == 200:
|
||||||
pool = await get_pg_pool()
|
data = resp.json()
|
||||||
if pool is None:
|
embedding = data["data"][0]["embedding"]
|
||||||
return []
|
_embed_cache_put(text, EMBEDDING_MODEL, embedding)
|
||||||
|
return embedding
|
||||||
# 获取查询向量
|
else:
|
||||||
query_embedding = await get_embedding(query)
|
logger.error(f"Embedding API error: {resp.status_code} {resp.text[:200]}")
|
||||||
if query_embedding is None:
|
return None
|
||||||
return []
|
except Exception as e:
|
||||||
|
logger.error(f"Embedding generation failed: {e}")
|
||||||
try:
|
return None
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# 将向量转换为 PostgreSQL 格式
|
|
||||||
embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
|
async def vector_search(
|
||||||
|
query: str,
|
||||||
if kb_id:
|
kb_id: Optional[str] = None,
|
||||||
rows = await conn.fetch(
|
top_k: int = 5,
|
||||||
"""
|
threshold: float = 0.5,
|
||||||
SELECT doc_id, chunk_id, content, metadata,
|
) -> List[Dict]:
|
||||||
1 - (embedding <=> $1::vector) as score
|
"""
|
||||||
FROM document_embeddings
|
向量语义检索
|
||||||
WHERE kb_id = $2
|
|
||||||
ORDER BY embedding <=> $1::vector
|
Args:
|
||||||
LIMIT $3;
|
query: 查询文本
|
||||||
""",
|
kb_id: 知识库ID (可选,不指定则搜索全部)
|
||||||
embedding_str, kb_id, top_k,
|
top_k: 返回结果数
|
||||||
)
|
threshold: 相似度阈值 (0-1)
|
||||||
else:
|
|
||||||
rows = await conn.fetch(
|
Returns:
|
||||||
"""
|
[{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
|
||||||
SELECT doc_id, chunk_id, content, metadata,
|
"""
|
||||||
1 - (embedding <=> $1::vector) as score
|
pool = await get_pg_pool()
|
||||||
FROM document_embeddings
|
if pool is None:
|
||||||
ORDER BY embedding <=> $1::vector
|
return []
|
||||||
LIMIT $2;
|
|
||||||
""",
|
# 获取查询向量
|
||||||
embedding_str, top_k,
|
query_embedding = await get_embedding(query)
|
||||||
)
|
if query_embedding is None:
|
||||||
|
return []
|
||||||
results = []
|
|
||||||
for row in rows:
|
try:
|
||||||
score = float(row["score"])
|
async with pool.acquire() as conn:
|
||||||
if score >= threshold:
|
# 将向量转换为 PostgreSQL 格式
|
||||||
results.append({
|
embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
|
||||||
"doc_id": row["doc_id"],
|
|
||||||
"chunk_id": row["chunk_id"],
|
if kb_id:
|
||||||
"content": row["content"],
|
rows = await conn.fetch(
|
||||||
"score": score,
|
"""
|
||||||
"metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
})
|
1 - (embedding <=> $1::vector) as score
|
||||||
|
FROM document_embeddings
|
||||||
return results
|
WHERE kb_id = $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
except Exception as e:
|
LIMIT $3;
|
||||||
logger.error(f"Vector search failed: {e}")
|
""",
|
||||||
return []
|
embedding_str, kb_id, top_k,
|
||||||
|
)
|
||||||
|
else:
|
||||||
async def index_document(
|
rows = await conn.fetch(
|
||||||
doc_id: str,
|
"""
|
||||||
kb_id: str,
|
SELECT doc_id, chunk_id, content, metadata,
|
||||||
content: str,
|
1 - (embedding <=> $1::vector) as score
|
||||||
chunk_id: Optional[str] = None,
|
FROM document_embeddings
|
||||||
metadata: Optional[Dict] = None,
|
ORDER BY embedding <=> $1::vector
|
||||||
) -> bool:
|
LIMIT $2;
|
||||||
"""
|
""",
|
||||||
将文档内容索引到 PGVector
|
embedding_str, top_k,
|
||||||
|
)
|
||||||
Args:
|
|
||||||
doc_id: 文档ID
|
results = []
|
||||||
kb_id: 知识库ID
|
for row in rows:
|
||||||
content: 文档内容
|
score = float(row["score"])
|
||||||
chunk_id: 分块ID (可选)
|
if score >= threshold:
|
||||||
metadata: 元数据
|
results.append({
|
||||||
|
"doc_id": row["doc_id"],
|
||||||
Returns:
|
"chunk_id": row["chunk_id"],
|
||||||
是否成功
|
"content": row["content"],
|
||||||
"""
|
"score": score,
|
||||||
pool = await get_pg_pool()
|
"metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
|
||||||
if pool is None:
|
})
|
||||||
return False
|
|
||||||
|
return results
|
||||||
# 分块处理(简单按长度分块)
|
|
||||||
chunks = []
|
except Exception as e:
|
||||||
chunk_size = 500
|
logger.error(f"Vector search failed: {e}")
|
||||||
overlap = 50
|
return []
|
||||||
if len(content) <= chunk_size:
|
|
||||||
chunks = [(content, chunk_id or f"{doc_id}_0")]
|
|
||||||
else:
|
async def index_document(
|
||||||
for i in range(0, len(content), chunk_size - overlap):
|
doc_id: str,
|
||||||
chunk = content[i:i + chunk_size]
|
kb_id: str,
|
||||||
if len(chunk.strip()) > 10:
|
content: str,
|
||||||
chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
|
chunk_id: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
success_count = 0
|
) -> bool:
|
||||||
for chunk_text, cid in chunks:
|
"""
|
||||||
embedding = await get_embedding(chunk_text)
|
将文档内容索引到 PGVector
|
||||||
if embedding is None:
|
|
||||||
continue
|
Args:
|
||||||
|
doc_id: 文档ID
|
||||||
try:
|
kb_id: 知识库ID
|
||||||
async with pool.acquire() as conn:
|
content: 文档内容
|
||||||
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
chunk_id: 分块ID (可选)
|
||||||
import json
|
metadata: 元数据
|
||||||
await conn.execute(
|
|
||||||
"""
|
Returns:
|
||||||
INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
|
是否成功
|
||||||
VALUES ($1, $2, $3, $4, $5::vector, $6)
|
"""
|
||||||
ON CONFLICT DO NOTHING;
|
pool = await get_pg_pool()
|
||||||
""",
|
if pool is None:
|
||||||
doc_id, kb_id, cid, chunk_text, embedding_str,
|
return False
|
||||||
json.dumps(metadata or {}),
|
|
||||||
)
|
# 分块处理(简单按长度分块)
|
||||||
success_count += 1
|
chunks = []
|
||||||
except Exception as e:
|
chunk_size = 500
|
||||||
logger.error(f"Failed to index chunk {cid}: {e}")
|
overlap = 50
|
||||||
|
if len(content) <= chunk_size:
|
||||||
logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
|
chunks = [(content, chunk_id or f"{doc_id}_0")]
|
||||||
return success_count > 0
|
else:
|
||||||
|
for i in range(0, len(content), chunk_size - overlap):
|
||||||
|
chunk = content[i:i + chunk_size]
|
||||||
async def hybrid_search(
|
if len(chunk.strip()) > 10:
|
||||||
query: str,
|
chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
|
||||||
kb_id: Optional[str] = None,
|
|
||||||
top_k: int = 5,
|
success_count = 0
|
||||||
bm25_results: Optional[List[Dict]] = None,
|
for chunk_text, cid in chunks:
|
||||||
) -> List[Dict]:
|
embedding = await get_embedding(chunk_text)
|
||||||
"""
|
if embedding is None:
|
||||||
混合检索:BM25 + 向量检索融合
|
continue
|
||||||
|
|
||||||
Args:
|
try:
|
||||||
query: 查询文本
|
async with pool.acquire() as conn:
|
||||||
kb_id: 知识库ID
|
embedding_str = f"[{','.join(str(x) for x in embedding)}]"
|
||||||
top_k: 返回结果数
|
import json
|
||||||
bm25_results: BM25 检索结果(来自MySQL)
|
await conn.execute(
|
||||||
|
"""
|
||||||
Returns:
|
INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
|
||||||
融合后的检索结果
|
VALUES ($1, $2, $3, $4, $5::vector, $6)
|
||||||
"""
|
ON CONFLICT DO NOTHING;
|
||||||
# 获取向量检索结果
|
""",
|
||||||
vec_results = await vector_search(query, kb_id, top_k)
|
doc_id, kb_id, cid, chunk_text, embedding_str,
|
||||||
|
json.dumps(metadata or {}),
|
||||||
if not vec_results and not bm25_results:
|
)
|
||||||
return []
|
success_count += 1
|
||||||
|
except Exception as e:
|
||||||
if not vec_results:
|
logger.error(f"Failed to index chunk {cid}: {e}")
|
||||||
return bm25_results or []
|
|
||||||
|
logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
|
||||||
if not bm25_results:
|
return success_count > 0
|
||||||
return vec_results
|
|
||||||
|
|
||||||
# 融合:Reciprocal Rank Fusion (RRF)
|
async def hybrid_search(
|
||||||
rrf_k = 60
|
query: str,
|
||||||
score_map = {}
|
kb_id: Optional[str] = None,
|
||||||
|
top_k: int = 5,
|
||||||
for rank, item in enumerate(bm25_results):
|
bm25_results: Optional[List[Dict]] = None,
|
||||||
doc_id = item.get("doc_id", item.get("id", ""))
|
) -> List[Dict]:
|
||||||
score_map[doc_id] = {
|
"""
|
||||||
"content": item.get("content", item.get("text", "")),
|
混合检索:BM25 + 向量检索融合
|
||||||
"score": 1.0 / (rrf_k + rank + 1),
|
|
||||||
"bm25_rank": rank + 1,
|
Args:
|
||||||
"metadata": item.get("metadata", {}),
|
query: 查询文本
|
||||||
}
|
kb_id: 知识库ID
|
||||||
|
top_k: 返回结果数
|
||||||
for rank, item in enumerate(vec_results):
|
bm25_results: BM25 检索结果(来自MySQL)
|
||||||
doc_id = item["doc_id"]
|
|
||||||
if doc_id in score_map:
|
Returns:
|
||||||
score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
|
融合后的检索结果
|
||||||
score_map[doc_id]["vec_score"] = item["score"]
|
"""
|
||||||
score_map[doc_id]["vec_rank"] = rank + 1
|
# 获取向量检索结果
|
||||||
else:
|
vec_results = await vector_search(query, kb_id, top_k)
|
||||||
score_map[doc_id] = {
|
|
||||||
"content": item["content"],
|
if not vec_results and not bm25_results:
|
||||||
"score": 1.0 / (rrf_k + rank + 1),
|
return []
|
||||||
"vec_score": item["score"],
|
|
||||||
"vec_rank": rank + 1,
|
if not vec_results:
|
||||||
"metadata": item.get("metadata", {}),
|
return bm25_results or []
|
||||||
}
|
|
||||||
|
if not bm25_results:
|
||||||
# 按融合分数排序
|
return vec_results
|
||||||
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
|
||||||
return merged[:top_k]
|
# 融合:Reciprocal Rank Fusion (RRF)
|
||||||
|
rrf_k = 60
|
||||||
|
score_map = {}
|
||||||
async def delete_document_embeddings(doc_id: str) -> bool:
|
|
||||||
"""删除文档的所有向量索引"""
|
for rank, item in enumerate(bm25_results):
|
||||||
pool = await get_pg_pool()
|
doc_id = item.get("doc_id", item.get("id", ""))
|
||||||
if pool is None:
|
score_map[doc_id] = {
|
||||||
return False
|
"content": item.get("content", item.get("text", "")),
|
||||||
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
try:
|
"bm25_rank": rank + 1,
|
||||||
async with pool.acquire() as conn:
|
"metadata": item.get("metadata", {}),
|
||||||
await conn.execute(
|
}
|
||||||
"DELETE FROM document_embeddings WHERE doc_id = $1;",
|
|
||||||
doc_id,
|
for rank, item in enumerate(vec_results):
|
||||||
)
|
doc_id = item["doc_id"]
|
||||||
logger.info(f"Deleted embeddings for document {doc_id}")
|
if doc_id in score_map:
|
||||||
return True
|
score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
|
||||||
except Exception as e:
|
score_map[doc_id]["vec_score"] = item["score"]
|
||||||
logger.error(f"Failed to delete embeddings: {e}")
|
score_map[doc_id]["vec_rank"] = rank + 1
|
||||||
return False
|
else:
|
||||||
|
score_map[doc_id] = {
|
||||||
|
"content": item["content"],
|
||||||
async def close_pool():
|
"score": 1.0 / (rrf_k + rank + 1),
|
||||||
"""关闭连接池"""
|
"vec_score": item["score"],
|
||||||
global _pg_pool
|
"vec_rank": rank + 1,
|
||||||
if _pg_pool is not None:
|
"metadata": item.get("metadata", {}),
|
||||||
await _pg_pool.close()
|
}
|
||||||
_pg_pool = None
|
|
||||||
logger.info("PostgreSQL connection pool closed")
|
# 按融合分数排序
|
||||||
|
merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
|
||||||
|
return merged[:top_k]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_document_embeddings(doc_id: str) -> bool:
|
||||||
|
"""删除文档的所有向量索引"""
|
||||||
|
pool = await get_pg_pool()
|
||||||
|
if pool is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM document_embeddings WHERE doc_id = $1;",
|
||||||
|
doc_id,
|
||||||
|
)
|
||||||
|
logger.info(f"Deleted embeddings for document {doc_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete embeddings: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def close_pool():
|
||||||
|
"""关闭连接池"""
|
||||||
|
global _pg_pool
|
||||||
|
if _pg_pool is not None:
|
||||||
|
await _pg_pool.close()
|
||||||
|
_pg_pool = None
|
||||||
|
logger.info("PostgreSQL connection pool closed")
|
||||||
@@ -81,6 +81,41 @@ 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.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not apps:
|
||||||
|
return PLATFORM_KNOWLEDGE
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"## 平台已上线技能(官方,优先推荐)",
|
||||||
|
"以下为平台当前已发布上线的技能/应用,回答用户相关问题时**优先推荐使用**这些平台能力:",
|
||||||
|
]
|
||||||
|
for a in apps:
|
||||||
|
name = getattr(a, "name", None) or "未命名"
|
||||||
|
cap = getattr(a, "capability", None) or getattr(a, "category", None) or "通用"
|
||||||
|
desc = (getattr(a, "description", "") or "").strip().replace("\n", " ")
|
||||||
|
if len(desc) > 60:
|
||||||
|
desc = desc[:60] + "…"
|
||||||
|
lines.append(f"- **{name}**({cap}): {desc}")
|
||||||
|
return PLATFORM_KNOWLEDGE + "\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[platform_knowledge] 动态构建失败,回退静态知识库: {e}")
|
||||||
|
return PLATFORM_KNOWLEDGE
|
||||||
+682
-676
File diff suppressed because it is too large.
Load diff
@@ -457,6 +457,64 @@ const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '',
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== AI 思考 / 执行过程面板(顶部展开 + 自动收拢) =====
|
||||||
|
// autoExpand=true 时(本轮刚产生的助手消息)首屏展开呈现思考链路,
|
||||||
|
// 2.6s 后若用户未手动干预则自动收拢为一行摘要,避免与回复内容冲突。
|
||||||
|
const AiProcessBlock: React.FC<{ steps: any[]; autoExpand: boolean }> = ({ steps, autoExpand }) => {
|
||||||
|
const [open, setOpen] = useState(autoExpand);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const touchedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoExpand) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
touchedRef.current = false;
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
if (!touchedRef.current) setOpen(false);
|
||||||
|
}, 2600);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [autoExpand]);
|
||||||
|
|
||||||
|
if (!steps || steps.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
touchedRef.current = true;
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setOpen(o => !o);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="ai-process" open={open}>
|
||||||
|
<summary className="ai-process-summary" onClick={(e) => { e.preventDefault(); toggle(); }}>
|
||||||
|
<span className="ai-process-badge">🧠</span>
|
||||||
|
思考 / 执行过程({steps.length} 步,{open ? '点击收起' : '点击展开'})
|
||||||
|
</summary>
|
||||||
|
<div className="ai-process-list">
|
||||||
|
{steps.map((s: any, i: number) => (
|
||||||
|
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
||||||
|
<span className="ai-process-ico">
|
||||||
|
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
||||||
|
</span>
|
||||||
|
<div className="ai-process-body">
|
||||||
|
{s.type === 'thinking' && (<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>)}
|
||||||
|
{s.type === 'skill' && (<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||||
|
{s.type === 'mcp' && (<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Main Component =====
|
// ===== Main Component =====
|
||||||
const ChatPage: React.FC = () => {
|
const ChatPage: React.FC = () => {
|
||||||
const { id } = useParams<{ id?: string }>();
|
const { id } = useParams<{ id?: string }>();
|
||||||
@@ -470,6 +528,8 @@ const ChatPage: React.FC = () => {
|
|||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [loadingConversations, setLoadingConversations] = useState(true);
|
const [loadingConversations, setLoadingConversations] = useState(true);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
// 本轮对话新产生的助手消息 id 集合,用于触发"思考过程"首屏展开
|
||||||
|
const [freshMsgIds, setFreshMsgIds] = useState<Set<string>>(new Set());
|
||||||
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
||||||
const [agents, setAgents] = useState<any[]>([]);
|
const [agents, setAgents] = useState<any[]>([]);
|
||||||
const [skills, setSkills] = useState<any[]>([]);
|
const [skills, setSkills] = useState<any[]>([]);
|
||||||
@@ -901,6 +961,11 @@ const ChatPage: React.FC = () => {
|
|||||||
return [...filtered, ...newMsgs];
|
return [...filtered, ...newMsgs];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 标记本轮新产生的助手消息,触发"思考过程"首屏展开
|
||||||
|
if (data.assistant_message) {
|
||||||
|
setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id));
|
||||||
|
}
|
||||||
|
|
||||||
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
// 媒体异步生成:启动轮询,待后端回写后自动刷新
|
||||||
if (data.assistant_message?.generating) {
|
if (data.assistant_message?.generating) {
|
||||||
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
|
||||||
@@ -1174,6 +1239,9 @@ const ChatPage: React.FC = () => {
|
|||||||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||||||
|
{msg.role === 'assistant' && steps.length > 0 && (
|
||||||
|
<AiProcessBlock steps={steps} autoExpand={freshMsgIds.has(msg.id)} />
|
||||||
|
)}
|
||||||
<div className={`message-bubble ${msg.role}`}>
|
<div className={`message-bubble ${msg.role}`}>
|
||||||
{clarify ? (
|
{clarify ? (
|
||||||
<ClarifyCard
|
<ClarifyCard
|
||||||
@@ -1237,34 +1305,6 @@ const ChatPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{msg.role === 'assistant' && steps.length > 0 && (
|
|
||||||
<details className="ai-process" open={false}>
|
|
||||||
<summary className="ai-process-summary">
|
|
||||||
<span className="ai-process-badge">🧠</span>
|
|
||||||
思考 / 执行过程({steps.length} 步,点击展开)
|
|
||||||
</summary>
|
|
||||||
<div className="ai-process-list">
|
|
||||||
{steps.map((s: any, i: number) => (
|
|
||||||
<div key={i} className={`ai-process-item ai-process-${s.type}`}>
|
|
||||||
<span className="ai-process-ico">
|
|
||||||
{s.type === 'thinking' ? '💡' : s.type === 'skill' ? '🛠' : '🔌'}
|
|
||||||
</span>
|
|
||||||
<div className="ai-process-body">
|
|
||||||
{s.type === 'thinking' && (
|
|
||||||
<><b className="ai-process-title">思考</b><p className="ai-process-text">{s.text}</p></>
|
|
||||||
)}
|
|
||||||
{s.type === 'skill' && (
|
|
||||||
<><b className="ai-process-title">调用技能:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
|
||||||
)}
|
|
||||||
{s.type === 'mcp' && (
|
|
||||||
<><b className="ai-process-title">MCP · {s.server}:{s.name}</b><p className="ai-process-text">{(s.result || '').slice(0, 400)}</p></>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
<div className={`message-meta ${msg.role}`}>
|
<div className={`message-meta ${msg.role}`}>
|
||||||
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
||||||
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
||||||
|
|||||||
@@ -1000,51 +1000,7 @@
|
|||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.evo-anim-toggle {
|
/* 技能图谱动画已在 #717 中移除(边框光晕溢出且无实际用途) */
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.evo-anim-toggle input {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
accent-color: var(--color-accent);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Baby sprout animation */
|
|
||||||
.evo-skill-node.baby.anim-on .evo-node-dot {
|
|
||||||
animation: baby-sprout 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.evo-skill-node.baby.anim-on {
|
|
||||||
animation: baby-glow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes baby-sprout {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.15); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes baby-glow {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.3); }
|
|
||||||
50% { box-shadow: 0 0 16px 4px rgba(245, 158, 11, 0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Staging breathe animation */
|
|
||||||
.evo-skill-node.staging.anim-on {
|
|
||||||
animation: staging-breathe 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes staging-breathe {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
|
||||||
50% { transform: scale(1.06); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.evo-toast {
|
.evo-toast {
|
||||||
@@ -1535,47 +1491,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== v2: Baby Sprout Animation (GAP-03) ===== */
|
/* ===== v2: 技能图谱动画已在 #717 中移除(边框溢出且无实际用途) ===== */
|
||||||
.evo-skill-node.state-baby.anim-on {
|
|
||||||
animation: baby-sprout 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes baby-sprout {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
box-shadow: 0 0 0 0 rgba(255, 182, 193, 0.4);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
box-shadow: 0 0 20px 4px rgba(255, 182, 193, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Baby skill sprout icon */
|
|
||||||
.evo-skill-node.state-baby.anim-on .evo-node-dot::after {
|
|
||||||
content: '🌱';
|
|
||||||
position: absolute;
|
|
||||||
top: -18px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 14px;
|
|
||||||
animation: sprout-wiggle 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sprout-wiggle {
|
|
||||||
0%, 100% { transform: translateX(-50%) rotate(-5deg); }
|
|
||||||
50% { transform: translateX(-50%) rotate(5deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Staging breathe animation */
|
|
||||||
.evo-skill-node.state-staging.anim-on {
|
|
||||||
animation: staging-breathe 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes staging-breathe {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
||||||
50% { transform: scale(1.08); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== v2: Toast messages ===== */
|
/* ===== v2: Toast messages ===== */
|
||||||
.evo-toast {
|
.evo-toast {
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ const shareThemes = [
|
|||||||
// ==================== Panel A: Skill Tree ====================
|
// ==================== Panel A: Skill Tree ====================
|
||||||
const SkillTreePanel: React.FC<{
|
const SkillTreePanel: React.FC<{
|
||||||
skills: EvolutionSkill[];
|
skills: EvolutionSkill[];
|
||||||
animEnabled: boolean;
|
}> = ({ skills }) => {
|
||||||
onToggleAnim: () => void;
|
|
||||||
}> = ({ skills, animEnabled, onToggleAnim }) => {
|
|
||||||
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
|
const [hoveredSkill, setHoveredSkill] = useState<string | null>(null);
|
||||||
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
|
const [selectedSkill, setSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||||
@@ -136,14 +134,6 @@ const SkillTreePanel: React.FC<{
|
|||||||
{/* Publish toast */}
|
{/* Publish toast */}
|
||||||
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
|
{publishMsg && <div className="evo-toast evo-toast-info">{publishMsg}</div>}
|
||||||
|
|
||||||
{/* Anim Toggle */}
|
|
||||||
<div className="evo-panel-toolbar">
|
|
||||||
<label className="evo-anim-toggle">
|
|
||||||
<input type="checkbox" checked={animEnabled} onChange={onToggleAnim} />
|
|
||||||
<span>动画效果</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skill detail modal */}
|
{/* Skill detail modal */}
|
||||||
{selectedSkill && !showVersionHistory && (
|
{selectedSkill && !showVersionHistory && (
|
||||||
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
|
<div className="evo-skill-detail-modal" onClick={() => setSelectedSkill(null)}>
|
||||||
@@ -305,7 +295,7 @@ const SkillTreePanel: React.FC<{
|
|||||||
<React.Fragment key={skill.id}>
|
<React.Fragment key={skill.id}>
|
||||||
{idx > 0 && <div className="evo-node-connector" />}
|
{idx > 0 && <div className="evo-node-connector" />}
|
||||||
<div
|
<div
|
||||||
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''} ${animEnabled ? 'anim-on' : ''}`}
|
className={`evo-skill-node ${skill.state} ${isHovered ? 'hovered' : ''}`}
|
||||||
onMouseEnter={() => setHoveredSkill(skill.id)}
|
onMouseEnter={() => setHoveredSkill(skill.id)}
|
||||||
onMouseLeave={() => setHoveredSkill(null)}
|
onMouseLeave={() => setHoveredSkill(null)}
|
||||||
onClick={() => setSelectedSkill(skill)}
|
onClick={() => setSelectedSkill(skill)}
|
||||||
@@ -944,22 +934,10 @@ const EvolutionDashboard: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
|
const [globalSelectedSkill, setGlobalSelectedSkill] = useState<EvolutionSkill | null>(null);
|
||||||
const [animEnabled, setAnimEnabled] = useState(() => {
|
|
||||||
return localStorage.getItem('evo_anim_enabled') !== 'false';
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalSkills = skills.length;
|
const totalSkills = skills.length;
|
||||||
const mainCount = skills.filter(s => s.state === 'main').length;
|
const mainCount = skills.filter(s => s.state === 'main').length;
|
||||||
const babyCount = skills.filter(s => s.state === 'baby').length;
|
const babyCount = skills.filter(s => s.state === 'baby').length;
|
||||||
|
|
||||||
const toggleAnim = useCallback(() => {
|
|
||||||
setAnimEnabled(prev => {
|
|
||||||
const next = !prev;
|
|
||||||
localStorage.setItem('evo_anim_enabled', String(next));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewSkill = (skillName: string) => {
|
const handleViewSkill = (skillName: string) => {
|
||||||
const skill = skills.find(s => s.skill_name === skillName);
|
const skill = skills.find(s => s.skill_name === skillName);
|
||||||
if (skill) {
|
if (skill) {
|
||||||
@@ -1071,7 +1049,7 @@ const EvolutionDashboard: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
<main className="evo-main">
|
<main className="evo-main">
|
||||||
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} animEnabled={animEnabled} onToggleAnim={toggleAnim} />}
|
{activePanel === 'skill-tree' && <SkillTreePanel skills={skills} />}
|
||||||
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
|
{activePanel === 'timeline' && <TimelinePanel events={events} onViewSkill={handleViewSkill} />}
|
||||||
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
{activePanel === 'statistics' && (stats ? <StatisticsPanel stats={stats} onViewSkill={handleViewSkill} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||||
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
{activePanel === 'profile' && (profile ? <ProfilePanel profile={profile} skills={skills} /> : <div className="evo-panel-content"><p>加载中...</p></div>)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
-- 018: 将 market_apps.capability 从旧的「AI 技术能力」取值规范为「业务场景」取值
|
-- 018: 将 market_apps.capability 从旧的「AI 技术能力」取值规范为「业务场景」取值
|
||||||
-- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
|
-- 旧的 text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video 已不再使用,
|
||||||
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
|
-- 统一置空,后续由 AI 验证 / 导入阶段按业务场景重新归类。
|
||||||
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
|
-- 幂等:执行一次后不再有匹配行,可安全每次重启重跑。
|
||||||
UPDATE market_apps
|
-- 注意:当前业务场景标准 = 前台「应用市场」14 类 (MARKET_SCENES):
|
||||||
SET capability = NULL
|
-- design, frontend, mobile, backend, qa, search, docs, marketing,
|
||||||
WHERE capability IS NOT NULL
|
-- workflow, secops, learning, video, collab, ai
|
||||||
AND capability NOT IN (
|
-- 这些为合法取值,必须保留,不得置空(#707 修复:此前 allowlist 不含 14 类导致重启被清空)。
|
||||||
'freelance_content', 'freelance_client', 'freelance_finance',
|
UPDATE market_apps
|
||||||
'office_doc', 'office_data', 'office_email', 'office_pm',
|
SET capability = NULL
|
||||||
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',
|
WHERE capability IS NOT NULL
|
||||||
'biz_product', 'biz_service', 'biz_ops',
|
AND capability NOT IN (
|
||||||
'general_write', 'general_research', 'general_agent'
|
'design', 'frontend', 'mobile', 'backend', 'qa', 'search', 'docs', 'marketing',
|
||||||
);
|
'workflow', 'secops', 'learning', 'video', 'collab', 'ai',
|
||||||
|
'freelance_content', 'freelance_client', 'freelance_finance',
|
||||||
|
'office_doc', 'office_data', 'office_email', 'office_pm',
|
||||||
|
'edu_prepare', 'edu_grade', 'edu_tutor', 'edu_home',
|
||||||
|
'biz_product', 'biz_service', 'biz_ops',
|
||||||
|
'general_write', 'general_research', 'general_agent'
|
||||||
|
);
|
||||||
Reference in new issue
Block a user