fix: 修复前台4项问题(MCP列举/微信技能上限/Word资产栏/技能MCP反馈) + 同步容器live状态
- #549 平台MCP列举: 注入全量已启用MCP清单, AI不再凭空编造 - #550 微信Bot技能自动发现上限 15->300 - #551 知识库Word文档同步资产后Word栏可见 + 双向删除同步(assets/knowledge级联软删) - #552 对话反馈实际使用的技能/MCP(used_mcp字段), 含历史消息序列化 - 同步容器内其他未提交live改动(im_bot_platform/TokenDashboard/WechatBot/ChatPage)
This commit is contained in:
1 parent
7fb7c07f57
commit
1578d68f95
10 files changed
+231
-23
No files matched your search
@@ -368,6 +368,19 @@ def delete_asset(
|
||||
asset.status = "deleted"
|
||||
asset.updated_at = datetime.now()
|
||||
|
||||
# 若资产由知识库文档同步而来,双向删除对应的知识库文档
|
||||
if asset.knowledge_doc_id:
|
||||
try:
|
||||
kb_doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == asset.knowledge_doc_id).first()
|
||||
if kb_doc:
|
||||
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_doc.knowledge_base_id).first()
|
||||
if kb:
|
||||
kb.document_count = max(0, (kb.document_count or 0) - 1)
|
||||
kb.updated_at = datetime.now()
|
||||
db.delete(kb_doc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=current_user.id, username=current_user.username, action="delete",
|
||||
target="asset", target_id=asset.id,
|
||||
|
||||
@@ -4094,7 +4094,28 @@ def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
|
||||
|
||||
|
||||
|
||||
def _discover_installed_skills(db: Session, user_id: str, limit: int = 15) -> list:
|
||||
def _list_enabled_mcp_servers(db: Session) -> list:
|
||||
"""返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
|
||||
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
|
||||
out = []
|
||||
for s in servers:
|
||||
try:
|
||||
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
|
||||
except Exception:
|
||||
headers = {}
|
||||
out.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description or "",
|
||||
"transport": s.transport_type or "streamable_http",
|
||||
"endpoint": s.url or "",
|
||||
"url": s.url or "",
|
||||
"headers": headers,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _discover_installed_skills(db: Session, user_id: str, limit: int = 300) -> list:
|
||||
|
||||
|
||||
|
||||
@@ -5311,6 +5332,7 @@ def _build_system_prompt(
|
||||
mcp_functions: list = None,
|
||||
|
||||
auto_discovered: bool = False,
|
||||
all_mcp: list = None,
|
||||
|
||||
) -> tuple:
|
||||
|
||||
@@ -5745,6 +5767,15 @@ def _build_system_prompt(
|
||||
|
||||
|
||||
|
||||
# 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造)
|
||||
if all_mcp:
|
||||
_mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp])
|
||||
system_parts.append(
|
||||
f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n"
|
||||
"当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能,"
|
||||
"不要编造或遗漏,也不要把未列出的服务说成已接入。"
|
||||
)
|
||||
|
||||
# MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址)
|
||||
|
||||
|
||||
@@ -7074,6 +7105,7 @@ def get_messages(
|
||||
|
||||
|
||||
"used_skills": msg.used_skills or [],
|
||||
"used_mcp": msg.used_mcp or [],
|
||||
|
||||
|
||||
|
||||
@@ -8090,6 +8122,7 @@ async def send_message(
|
||||
mcp_functions=mcp_function_definitions,
|
||||
|
||||
auto_discovered=used_auto_skills,
|
||||
all_mcp=_list_enabled_mcp_servers(db),
|
||||
|
||||
)
|
||||
|
||||
@@ -8103,6 +8136,10 @@ async def send_message(
|
||||
|
||||
|
||||
|
||||
# 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部)
|
||||
called_skill_names = []
|
||||
called_mcp_names = []
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
|
||||
@@ -8433,6 +8470,8 @@ async def send_message(
|
||||
|
||||
)
|
||||
|
||||
called_mcp_names.append(_srv.get("name", ""))
|
||||
|
||||
else:
|
||||
|
||||
tool_result = _execute_skill_tool(
|
||||
@@ -8441,6 +8480,12 @@ async def send_message(
|
||||
|
||||
)
|
||||
|
||||
_sk = next((s for s in skills if s.get("func_name") == func_name), None)
|
||||
|
||||
if _sk:
|
||||
|
||||
called_skill_names.append(_sk.get("name", ""))
|
||||
|
||||
|
||||
|
||||
messages.append({
|
||||
@@ -8671,7 +8716,7 @@ async def send_message(
|
||||
|
||||
|
||||
|
||||
# 更新used_skills
|
||||
# 更新used_skills / used_mcp:优先反馈用户显式指定的;否则反馈本轮实际调用的
|
||||
|
||||
|
||||
|
||||
@@ -8679,11 +8724,83 @@ async def send_message(
|
||||
|
||||
|
||||
|
||||
if skills:
|
||||
used_mcp = []
|
||||
|
||||
|
||||
|
||||
used_skills = [s["name"] for s in skills]
|
||||
if effective_skill_ids:
|
||||
|
||||
|
||||
|
||||
# 用户显式选择了技能 -> 反馈配对的技能名
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
|
||||
_sel = _resolve_market_skills(db, effective_skill_ids, user_id)
|
||||
|
||||
|
||||
|
||||
used_skills = [s["name"] for s in _sel]
|
||||
|
||||
|
||||
|
||||
except Exception:
|
||||
|
||||
|
||||
|
||||
used_skills = []
|
||||
|
||||
|
||||
|
||||
elif called_skill_names:
|
||||
|
||||
|
||||
|
||||
# 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空)
|
||||
|
||||
|
||||
|
||||
used_skills = list(dict.fromkeys(called_skill_names))
|
||||
|
||||
|
||||
|
||||
if effective_mcp_ids:
|
||||
|
||||
|
||||
|
||||
# 用户显式选择了 MCP -> 反馈配对的 MCP 名
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
|
||||
_sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids)
|
||||
|
||||
|
||||
|
||||
used_mcp = [m["name"] for m in _sel_mcp]
|
||||
|
||||
|
||||
|
||||
except Exception:
|
||||
|
||||
|
||||
|
||||
used_mcp = []
|
||||
|
||||
|
||||
|
||||
elif called_mcp_names:
|
||||
|
||||
|
||||
|
||||
used_mcp = list(dict.fromkeys(called_mcp_names))
|
||||
|
||||
|
||||
|
||||
@@ -8691,6 +8808,10 @@ async def send_message(
|
||||
|
||||
|
||||
|
||||
ai_msg.used_mcp = used_mcp
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8980,6 +9101,7 @@ async def send_message(
|
||||
|
||||
|
||||
"used_skills": used_skills,
|
||||
"used_mcp": used_mcp,
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ def _qihua_reply(text: str, user_id: str = None, platform: str = "im") -> str:
|
||||
try:
|
||||
from app.api.im_llm import _call_real_llm
|
||||
db = SessionLocal()
|
||||
return _call_real_llm(db, None, prompt, platform)
|
||||
return _call_real_llm(db, None, prompt, platform, user_id=user_id)
|
||||
except Exception as e:
|
||||
return f"(AI 回复生成失败:{e})"
|
||||
finally:
|
||||
|
||||
@@ -1149,15 +1149,21 @@ def _split_content_into_chunks(content: str, chunk_size: int = 500) -> list:
|
||||
def _sync_asset_for_knowledge_doc(db: Session, user_id: str, doc_id: str, filename: str, file_type: str, file_size: int):
|
||||
"""将知识库文档同步为资产记录(问题9:资产关联知识库文档用量)"""
|
||||
try:
|
||||
# 文件类型归一化为前端子分类标签(docx/doc->word 等),确保出现在「文件 > Word」子栏
|
||||
_ft_label = {
|
||||
"pdf": "pdf", "docx": "word", "doc": "word", "xlsx": "excel", "xls": "excel",
|
||||
"pptx": "ppt", "ppt": "ppt", "csv": "txt", "md": "md", "txt": "txt",
|
||||
}.get((file_type or "").lower(), "other")
|
||||
asset = Asset(
|
||||
id=gen_uuid(),
|
||||
user_id=user_id,
|
||||
name=filename,
|
||||
category="doc",
|
||||
category="file",
|
||||
description=f"知识库文档 (ID: {doc_id})",
|
||||
file_type=file_type,
|
||||
file_type=_ft_label,
|
||||
size=file_size,
|
||||
status="active",
|
||||
knowledge_doc_id=doc_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
@@ -1191,6 +1197,15 @@ def delete_document(
|
||||
pass # total_size removed
|
||||
kb.updated_at = datetime.now()
|
||||
|
||||
# 同步删除知识库文档对应的资产记录(双向同步)
|
||||
try:
|
||||
linked_asset = db.query(Asset).filter(Asset.knowledge_doc_id == doc_id).first()
|
||||
if linked_asset:
|
||||
linked_asset.status = "deleted"
|
||||
linked_asset.updated_at = datetime.now()
|
||||
except Exception as _e:
|
||||
logger.warning(f"同步删除关联资产失败: {_e}")
|
||||
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ class Message(Base):
|
||||
mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表")
|
||||
attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表")
|
||||
used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表")
|
||||
used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
|
||||
@@ -747,6 +748,7 @@ class Asset(Base):
|
||||
content = Column(Text, default=None)
|
||||
novel_meta = Column(JSON, default=None)
|
||||
status = Column(String(10), nullable=False, default="active") # active / deleted
|
||||
knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ function mapFileTypeToLabel(ft: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
|
||||
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
|
||||
word: 'word', excel: 'excel',
|
||||
};
|
||||
return mapping[ft] || 'other';
|
||||
}
|
||||
@@ -399,7 +400,7 @@ const AssetsPage: React.FC = () => {
|
||||
const VALID_CATEGORIES = ['file', 'novel', 'image', 'video', 'audio', 'custom', 'other'];
|
||||
const normalizeAsset = (item: any): Asset => {
|
||||
const rawCat = item.category;
|
||||
const ft = item.fileType || item.file_type || 'other';
|
||||
const ft = mapFileTypeToLabel(item.fileType || item.file_type || 'other');
|
||||
let category = rawCat;
|
||||
if (!VALID_CATEGORIES.includes(category)) {
|
||||
category = (ft && ft !== 'other') ? 'file' : 'other';
|
||||
|
||||
@@ -764,6 +764,7 @@ const ChatPage: React.FC = () => {
|
||||
tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
|
||||
createdAt: updated.createdAt || updated.created_at || m.createdAt,
|
||||
used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
|
||||
used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
|
||||
} : m));
|
||||
stopMediaPolling(msgId);
|
||||
} catch (e) {
|
||||
@@ -1039,6 +1040,7 @@ const ChatPage: React.FC = () => {
|
||||
tokenUsed: data.assistant_message.token_used || 0,
|
||||
createdAt: data.assistant_message.created_at || new Date().toISOString(),
|
||||
used_skills: data.assistant_message.used_skills || [],
|
||||
used_mcp: data.assistant_message.used_mcp || [],
|
||||
});
|
||||
}
|
||||
return [...filtered, ...newMsgs];
|
||||
@@ -1311,6 +1313,7 @@ const ChatPage: React.FC = () => {
|
||||
messages.map(msg => {
|
||||
// Real used_skills from API response
|
||||
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
||||
const usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
|
||||
// 模糊创作意图确认卡片
|
||||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||||
return (
|
||||
@@ -1357,6 +1360,27 @@ const ChatPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'assistant' && usedMcp.length > 0 && (
|
||||
<div className="evo-skill-tags-row">
|
||||
{usedMcp.slice(0, 3).map(mcpName => (
|
||||
<span
|
||||
key={mcpName}
|
||||
className="evo-skill-tag evo-mcp-tag"
|
||||
title={`使用了 MCP 应用:${mcpName}`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
</svg>
|
||||
{mcpName}
|
||||
</span>
|
||||
))}
|
||||
{usedMcp.length > 3 && (
|
||||
<span className="evo-skill-tag evo-skill-tag-more">+{usedMcp.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`message-meta ${msg.role}`}>
|
||||
<span className="message-time">{formatTime(msg.createdAt)}</span>
|
||||
<span className="message-tokens">{msg.tokenUsed} tokens</span>
|
||||
|
||||
@@ -160,6 +160,37 @@ function getMediaStats(logs: TokenUsageLog[]) {
|
||||
return { imageCount, videoCount, imageTokens, videoTokens, models };
|
||||
}
|
||||
|
||||
// 分页拉取全部 token 日志(后端 /logs 默认只返回 20 条,会导致前端上下翻页无法查看更早记录)。
|
||||
// 通过 page_size=100 循环拉取,直到不足一页为止。
|
||||
async function fetchAllTokenLogs(): Promise<TokenUsageLog[]> {
|
||||
const all: any[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const res = await tokenAPI.getLogs({ page_size: 100, page });
|
||||
const rawLogs = (res.data as any)?.items || res.data || [];
|
||||
if (!Array.isArray(rawLogs) || rawLogs.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
all.push(
|
||||
...rawLogs.map((item: any) => ({
|
||||
...item,
|
||||
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
|
||||
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
|
||||
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
|
||||
modelName: item.modelName ?? item.model_name ?? '',
|
||||
cost: item.cost ?? 0,
|
||||
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
|
||||
appName: item.appName ?? item.app_name ?? '',
|
||||
}))
|
||||
);
|
||||
if (rawLogs.length < 100) hasMore = false;
|
||||
else page += 1;
|
||||
}
|
||||
return all as TokenUsageLog[];
|
||||
}
|
||||
|
||||
const TokenDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||
@@ -178,28 +209,18 @@ const TokenDashboardPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [overviewRes, balanceRes, logsRes] = await Promise.all([
|
||||
const [overviewRes, balanceRes, allLogs] = await Promise.all([
|
||||
tokenAPI.getOverview(),
|
||||
tokenAPI.getBalance(),
|
||||
tokenAPI.getLogs(),
|
||||
fetchAllTokenLogs(),
|
||||
]);
|
||||
setTotalConsumed(overviewRes.data?.total_used || overviewRes.data?.total_consumed || overviewRes.data?.totalConsumed || 0);
|
||||
setBalance(balanceRes.data?.balance || 0);
|
||||
// 从overview响应中获取服务器时间
|
||||
const serverTimeStr = overviewRes.data?.server_time || overviewRes.data?.serverTime || overviewRes.data?.current_time || overviewRes.data?.time || '';
|
||||
setServerTime(serverTimeStr);
|
||||
// 兼容分页格式和直接数组两种返回格式
|
||||
const rawLogs = (logsRes.data as any)?.items || logsRes.data || [];
|
||||
// 兼容后端返回的 snake_case 字段名
|
||||
setLogs(rawLogs.map((item: any) => ({
|
||||
...item,
|
||||
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
|
||||
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
|
||||
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
|
||||
modelName: item.modelName ?? item.model_name ?? '',
|
||||
cost: item.cost ?? 0,
|
||||
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
|
||||
})));
|
||||
// 已拉取全部日志(fetchAllTokenLogs 内部循环分页),客户端可自由上下翻页
|
||||
setLogs(allLogs);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '数据加载失败');
|
||||
} finally {
|
||||
|
||||
@@ -93,7 +93,16 @@ const WechatBot: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-');
|
||||
// 后端按北京时间(Asia/Shanghai)存储连接时间(无时区标记),前端统一按北京时间展示,
|
||||
// 不受浏览器所在时区影响。
|
||||
const formatTime = (t: string) => {
|
||||
if (!t) return '-';
|
||||
const iso = t.includes('T') ? t : t.replace(' ', 'T');
|
||||
const withTz = iso.includes('+') || iso.includes('Z') ? iso : iso + '+08:00';
|
||||
const d = new Date(withTz);
|
||||
if (isNaN(d.getTime())) return t;
|
||||
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface Message {
|
||||
tokenUsed: number;
|
||||
createdAt: string;
|
||||
used_skills?: string[];
|
||||
used_mcp?: string[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user