前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key)

This commit is contained in:
QihuAI Design committed 2026-07-15 10:33:43 +08:00
1 parent 463824480b
commit bc2899ed09
11 files changed
+945 -154

No files matched your search

+146 -5
View File
@@ -66,6 +66,50 @@ def detect_media_intent(text: str) -> str | None:
return "audio"
return None
def detect_ambiguous_creative_intent(text: str):
"""检测「模糊创作意图」:用户使用了创作类动词(制作/创作/做/生成/画/写/设计…),
但未明确产出形式(文案/图片/视频/其他)。此时应与用户确认方向,而不是直接调用大模型
(某些模型会对这类开放式创作请求返回 "high risk" 拒答)。
返回 None 表示无需确认;否则返回 {"options": [...]}。
"""
if not text:
return None
t = text.strip()
tl = t.lower()
# 已有明确媒体意图(图像/视频/语音)则无需确认
if detect_media_intent(t):
return None
# 疑问句不触发
if tl.endswith(("?", "?")) or any(q in tl for q in ("吗", "呢", "怎么", "如何", "能否", "是否可以", "可以吗", "会不会", "why", "how", "什么", "哪些")):
return None
# 已明确给出产出形式(文字类/其他类)则无需确认
explicit_medium = (
"文案", "文章", "诗歌", "诗", "小说", "故事", "代码", "程序", "脚本", "总结", "翻译",
"邮件", "报告", "段落", "正文", "文字", "写作", "ppt", "演示", "思维导图", "表格",
"图表", "数据",
)
if any(m in tl for m in explicit_medium):
return None
# 创作类动词
creative_verbs = (
"制作", "创作", "做个", "做一个", "搞一个", "产出", "构思", "设想",
"打造", "塑造", "生成", "帮我做", "帮我生成", "帮我画", "帮我写", "请制作",
"请创作", "请生成", "请画", "请写", "来一个", "来一张", "来一幅", "做一张",
"做张", "写一首", "写一段", "写个",
)
if not any(v in t for v in creative_verbs):
return None
# 模糊:有创作动词但无明确产出形式 → 提供方向选择
options = [
{"key": "text", "label": "文案", "desc": "文章 / 故事 / 诗歌等文字内容", "medium": "文案"},
{"key": "image", "label": "图片", "desc": "插画 / 海报 / 照片等图像", "medium": "图片"},
{"key": "video", "label": "视频", "desc": "短片 / 动画等视频", "medium": "视频"},
{"key": "other", "label": "其他", "desc": "语音 / 代码 / 其他形式", "medium": "其他"},
]
return {"options": options}
# CST timezone (UTC+8) for consistent time display
CST_TZ = timezone(timedelta(hours=8))
@@ -243,6 +287,8 @@ class SendMessageRequest(BaseModel):
skill_ids: Optional[list] = None
agent_ids: Optional[list] = None
knowledge_base_ids: Optional[list] = None
mcp_ids: Optional[list] = None
attachments: Optional[list] = None
use_tools: Optional[bool] = False
@@ -374,7 +420,36 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
return results
def _parse_skill_content(content: str) -> dict:
def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list:
"""
加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。
返回: [{id, name, transport, endpoint, description}]
"""
if not mcp_ids:
return []
mcps = db.query(MarketApp).filter(
MarketApp.id.in_(mcp_ids),
MarketApp.type == "mcp"
).all()
results = []
for m in mcps:
config = {}
try:
if m.skill_content:
config = json.loads(m.skill_content)
except Exception:
config = {}
results.append({
"id": m.id,
"name": m.name,
"transport": m.mcp_transport or config.get("transport", "stdio"),
"endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""),
"description": m.description or config.get("description", ""),
})
return results
"""
解析SKILL.md格式内容, 提取结构化信息。
SKILL.md格式:
@@ -651,6 +726,7 @@ def _build_system_prompt(
agents: list,
kb_context: str,
user_content: str,
mcp: list = None,
) -> tuple:
"""
动态组装system prompt + function definitions。
@@ -736,6 +812,12 @@ def _build_system_prompt(
if kb_context:
system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}")
# MCP 应用注入
if mcp:
mcp_descs = ", ".join([f"{s['name']}({s.get('transport', 'stdio')})" for s in mcp])
system_parts.append(f"已连接的 MCP 应用(外部工具服务): {mcp_descs}")
system_parts.append("当用户请求需要调用外部系统/工具(如查询数据库、调用API、操作第三方服务)时,可参考上述 MCP 应用提供的能力;若平台已接入对应 MCP 工具,优先通过工具调用完成。")
system_prompt = "\n".join(system_parts)
return system_prompt, function_definitions
@@ -882,6 +964,8 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict:
context["agent_ids"] = first_tool_msg.agent_ids
if first_tool_msg.knowledge_ids:
context["knowledge_base_ids"] = first_tool_msg.knowledge_ids
if getattr(first_tool_msg, "mcp_ids", None):
context["mcp_ids"] = first_tool_msg.mcp_ids
return context
@@ -1068,33 +1152,88 @@ async def send_message(
effective_skill_ids = req.skill_ids or []
effective_agent_ids = req.agent_ids or []
effective_kb_ids = req.knowledge_base_ids or []
effective_mcp_ids = req.mcp_ids or []
effective_use_tools = req.use_tools or False
if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids:
if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids:
# 从对话上下文继承
prev_context = _get_conversation_context(db, conv_id)
if prev_context:
effective_skill_ids = prev_context.get("skill_ids", [])
effective_agent_ids = prev_context.get("agent_ids", [])
effective_kb_ids = prev_context.get("knowledge_base_ids", [])
if effective_skill_ids or effective_agent_ids:
effective_mcp_ids = prev_context.get("mcp_ids", [])
if effective_skill_ids or effective_agent_ids or effective_mcp_ids:
effective_use_tools = True
# 保存用户消息(含skill/agent/kb选择用于上下文保持)
# 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考)
attachment_block = ""
if req.attachments:
parts = []
for att in req.attachments:
name = att.get("name", "附件") if isinstance(att, dict) else str(att)
ftype = att.get("file_type", "") if isinstance(att, dict) else ""
content = att.get("content", "") if isinstance(att, dict) else ""
if content:
parts.append(f"【附件: {name}】\n{content}")
else:
parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】")
if parts:
attachment_block = "\n\n" + "\n\n".join(parts)
# 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持)
user_msg = Message(
id=gen_uuid(),
conversation_id=conv_id,
role="user",
content=req.content,
content=req.content + attachment_block,
token_used=len(req.content) // 2,
model_id=req.model_id or conv.model_id,
skill_ids=req.skill_ids if req.skill_ids else None,
agent_ids=req.agent_ids if req.agent_ids else None,
knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None,
mcp_ids=req.mcp_ids if req.mcp_ids else None,
attachments=req.attachments if req.attachments else None,
created_at=cst_now(),
)
db.add(user_msg)
# ===== 模糊创作意图拦截 =====
# 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向,
# 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。
clarify = detect_ambiguous_creative_intent(req.content)
if clarify:
clarify_content = "<<CLARIFY>>" + json.dumps(
{"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False
) + "<<CLARIFY>>"
ai_msg = Message(
id=gen_uuid(),
conversation_id=conv_id,
role="assistant",
content=clarify_content,
token_used=0,
model_id=req.model_id or conv.model_id,
created_at=cst_now(),
)
db.add(ai_msg)
db.commit()
db.refresh(user_msg)
db.refresh(ai_msg)
return {
"code": 0,
"data": {
"user_message": {
"id": user_msg.id, "role": user_msg.role, "content": user_msg.content,
"token_used": user_msg.token_used, "created_at": str(user_msg.created_at),
},
"assistant_message": {
"id": ai_msg.id, "role": ai_msg.role, "content": clarify_content,
"created_at": str(ai_msg.created_at), "clarify": clarify,
},
},
"message": "请选择创作方向",
}
# ===== 核心聊天引擎 =====
user_id = current_user.id
model_id = req.model_id or conv.model_id
@@ -1140,6 +1279,7 @@ async def send_message(
# Stage A+B: 解析Skills和Agents
skills = _resolve_market_skills(db, effective_skill_ids, user_id)
agents = _resolve_market_agents(db, effective_agent_ids, user_id)
mcps = _resolve_market_mcp(db, effective_mcp_ids, user_id)
# Stage B.1: Agent可能自带skill_ids和kb_ids
agent_extra_skill_ids = []
@@ -1171,6 +1311,7 @@ async def send_message(
agents=agents,
kb_context=kb_context,
user_content=req.content,
mcp=mcps,
)
# 构建消息列表
+5 -1
View File
@@ -58,6 +58,7 @@ class UploadAppRequest(BaseModel):
is_member_free: Optional[bool] = False
join_promotion: Optional[bool] = False
agent_endpoint: Optional[str] = None
api_key: Optional[str] = None
class PurchaseAppRequest(BaseModel):
@@ -119,6 +120,8 @@ def list_apps(
result = []
for app in apps:
# 用户自己创建/导入的应用应始终视为已安装(避免安装记录缺失导致显示未安装)
is_user_owned = bool(user_id) and app.source == "user" and app.publisher_id == user_id
item = {
"id": app.id,
"name": app.name,
@@ -136,7 +139,7 @@ def list_apps(
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"created_at": str(app.created_at) if app.created_at else None,
"installed": app.id in installed_app_ids if user_id else False,
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
}
result.append(item)
@@ -222,6 +225,7 @@ def upload_app(
is_member_free=req.is_member_free if req.is_member_free is not None else False,
join_promotion=req.join_promotion if req.join_promotion is not None else False,
agent_endpoint=req.agent_endpoint,
agent_api_key=req.api_key,
status="published", # 用户创建的应用直接发布
created_at=datetime.now(),
updated_at=datetime.now(),
+3
View File
@@ -102,6 +102,8 @@ class Message(Base):
knowledge_ids = Column(JSON, default=None)
agent_ids = Column(JSON, default=None)
skill_ids = Column(JSON, default=None)
mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表")
attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表")
used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表")
created_at = Column(DateTime, default=datetime.now)
@@ -293,6 +295,7 @@ class MarketApp(Base):
skill_content = Column(Text, default=None)
mcp_transport = Column(String(20), default=None)
agent_endpoint = Column(String(500), default=None)
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
+8 -3
View File
@@ -467,9 +467,14 @@ export const notificationAPI = {
export const assetsAPI = {
getAssets: (category?: string) =>
get<any[]>(`/assets/${category ? `?category=${category}` : ''}`),
getAssets: (params?: { category?: string; page?: number; page_size?: number }) => {
const qs: string[] = [];
if (params?.category) qs.push(`category=${encodeURIComponent(params.category)}`);
if (params?.page) qs.push(`page=${params.page}`);
if (params?.page_size) qs.push(`page_size=${params.page_size}`);
const q = qs.length ? `?${qs.join('&')}` : '';
return get<any>(`/assets/${q}`);
},
createAsset: (data: any) => post<any>('/assets/', data),
+34 -19
View File
@@ -392,24 +392,43 @@ const AssetsPage: React.FC = () => {
const [showCreate, setShowCreate] = useState(false);
const navigate = useNavigate();
const fetchAssets = async (category?: string) => {
// 将后端返回的资产标准化:
// 1. 统一 fileType(兼容 file_type 字段)
// 2. 兼容遗留的文档类分类(doc/document 等)与带文件类型但分类异常的资产,
// 统一归入 file 分类,确保它们出现在「文件」页签及其对应的子类型(如 pdf)中。
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';
let category = rawCat;
if (!VALID_CATEGORIES.includes(category)) {
category = (ft && ft !== 'other') ? 'file' : 'other';
}
return { ...item, category, fileType: ft };
};
// 一次性拉取当前用户全部资产(分页累加),分类与子类型筛选全部在前端完成,
// 避免后端按 category 过滤时漏掉历史遗留分类(如 doc)的资产。
const fetchAllAssets = async () => {
setLoading(true);
setError(null);
try {
const catParam = category && category !== 'all' ? category : undefined;
const res = await assetsAPI.getAssets(catParam);
// getAssets 返回分页格式 {total, page, page_size, items}
const rawItems = (res.data as any)?.items || res.data || [];
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
...item,
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
fileType: item.fileType || item.file_type || 'other',
}));
let all: any[] = [];
let page = 1;
const pageSize = 100;
for (;;) {
const res = await assetsAPI.getAssets({ page, page_size: pageSize });
const data = (res.data as any) || {};
const items: any[] = data.items || [];
const total: number = data.total || 0;
all = all.concat(items);
if (items.length === 0 || all.length >= total) break;
page += 1;
}
const normalized = all.map(normalizeAsset);
// 按id去重
const seen = new Set<string>();
const deduped = normalizedAssets.filter(a => {
const deduped = normalized.filter(a => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
@@ -451,13 +470,9 @@ const AssetsPage: React.FC = () => {
useEffect(() => {
fetchCategories();
fetchAssets();
fetchAllAssets();
}, []);
useEffect(() => {
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
}, [activeCategory]);
// Filter by sub-category for files
const filtered = (() => {
// 如果是文件分类,先确保只显示文件类资产
@@ -640,7 +655,7 @@ const AssetsPage: React.FC = () => {
{showCreate && (
<CreateAssetModal
onClose={() => setShowCreate(false)}
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
onCreated={() => fetchAllAssets()}
/>
)}
</div>
+172
View File
@@ -1257,3 +1257,175 @@
max-width: 92vw;
max-height: 86vh;
}
/* ===== Clarify Card (模糊创作意图方向确认) ===== */
.chat-clarify-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--color-bg-tertiary, #f5f7fb);
border: 1px solid var(--color-border-light, #e6e9f0);
border-radius: var(--radius-lg);
}
.chat-clarify-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.chat-clarify-sub {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
padding: 6px 10px;
background: var(--color-bg-secondary, #fff);
border-radius: var(--radius-sm);
border-left: 3px solid var(--color-accent, #5B5FED);
word-break: break-all;
}
.chat-clarify-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-sm);
}
.chat-clarify-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 10px 14px;
border: 1.5px solid var(--color-border, #dfe3ec);
border-radius: var(--radius-md);
background: var(--color-bg-secondary, #fff);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.chat-clarify-option:hover {
border-color: var(--color-accent, #5B5FED);
background: var(--color-accent-subtle, #eef0ff);
transform: translateY(-1px);
}
.chat-clarify-opt-label {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-accent, #5B5FED);
}
.chat-clarify-opt-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.4;
}
@media (max-width: 640px) {
.chat-clarify-options {
grid-template-columns: 1fr;
}
}
/* ===== 附件 chips ===== */
.attachment-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
padding: 8px 4px 0;
}
.attachment-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 5px 8px 5px 10px;
background: var(--color-bg-tertiary, #f5f7fb);
border: 1px solid var(--color-border-light, #e6e9f0);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: var(--color-text-secondary);
}
.attachment-chip svg {
flex: none;
color: var(--color-accent, #5B5FED);
}
.attachment-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-remove {
flex: none;
border: none;
background: transparent;
color: var(--color-text-tertiary);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.attachment-remove:hover {
color: #e5484d;
}
/* ===== 语音输入/朗读按钮 ===== */
.mic-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--color-border, #dfe3ec);
border-radius: var(--radius-md);
background: var(--color-bg-secondary, #fff);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.mic-btn:hover {
border-color: var(--color-accent, #5B5FED);
color: var(--color-accent, #5B5FED);
}
.mic-btn.recording {
background: #e5484d;
border-color: #e5484d;
color: #fff;
animation: mic-pulse 1.2s infinite;
}
@keyframes mic-pulse {
0% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.5); }
70% { box-shadow: 0 0 0 10px rgba(229, 72, 77, 0); }
100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); }
}
.msg-tts-btn {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid var(--color-border-light, #e6e9f0);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary, #fff);
color: var(--color-text-tertiary);
cursor: pointer;
font-size: var(--text-xs);
transition: all var(--transition-fast);
}
.msg-tts-btn:hover {
border-color: var(--color-accent, #5B5FED);
color: var(--color-accent, #5B5FED);
}
+260 -18
View File
@@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
return <div className="markdown-body">{nodes}</div>;
};
// 解析后端返回的「模糊创作意图」确认指令:<<CLARIFY>>{json}<<CLARIFY>>
function parseClarify(content: string): { prompt: string; options: any[] } | null {
if (!content || typeof content !== 'string') return null;
const m = content.match(/^<<CLARIFY>>([\s\S]+)<<CLARIFY>>$/);
if (!m) return null;
try {
const obj = JSON.parse(m[1]);
if (obj && Array.isArray(obj.options)) return obj;
} catch { /* ignore */ }
return null;
}
// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
return (
<div className="chat-clarify-card">
<div className="chat-clarify-title">🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?</div>
<div className="chat-clarify-sub">「{data.prompt}」</div>
<div className="chat-clarify-options">
{data.options.map((opt: any) => (
<button key={opt.key} className="chat-clarify-option" onClick={() => onChoose(opt)}>
<span className="chat-clarify-opt-label">{opt.label}</span>
<span className="chat-clarify-opt-desc">{opt.desc}</span>
</button>
))}
</div>
</div>
);
};
function escapeHtml(text: string): string {
return text
@@ -358,6 +388,37 @@ const IconSkill = () => (
</svg>
);
const IconMcp = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/>
<line x1="7.5" y1="7.5" x2="11" y2="16"/><line x1="16.5" y1="7.5" x2="13" y2="16"/>
</svg>
);
const IconPaperclip = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
);
const IconMic = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/>
</svg>
);
const IconStop = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>
);
const IconVolume = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
);
const IconModel = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
@@ -471,10 +532,17 @@ const ChatPage: React.FC = () => {
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]);
const [skills, setSkills] = useState<any[]>([]);
const [mcps, setMcps] = useState<any[]>([]);
// 多选组合模式
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [selectedKbIds, setSelectedKbIds] = useState<string[]>([]);
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([]);
// 文件附件
const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const recognitionRef = useRef<any>(null);
const [recording, setRecording] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -655,6 +723,13 @@ const ChatPage: React.FC = () => {
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
}).catch(() => {});
marketAPI.getApps({ type: 'mcp' }).then((res: any) => {
const data = res?.data !== undefined ? res.data : res;
let apps: any[] = [];
if (Array.isArray(data)) apps = data;
else if (data?.items) apps = data.items;
setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
}).catch(() => {});
}, []);
// Determine selected conversation
@@ -822,10 +897,9 @@ const ChatPage: React.FC = () => {
};
// Handle send message - CALLS REAL API
const handleSend = async () => {
if (!inputValue.trim() || sending) return;
const content = inputValue.trim();
setInputValue('');
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
const sendText = async (content: string) => {
if (!content.trim() || sending || !selectedConversation) return;
setSending(true);
// Add user message optimistically
@@ -846,7 +920,11 @@ const ChatPage: React.FC = () => {
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
attachments: attachments.length > 0 ? attachments.map(a => ({
name: a.name, file_type: a.file_type, content: a.content || '',
})) : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
});
const data = unwrapData(res);
@@ -886,6 +964,8 @@ const ChatPage: React.FC = () => {
// Refresh conversations to update lastMessage
loadConversations();
// 发送后清空本次附件(选择状态仍保留)
setAttachments([]);
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
if (selectedConversation?.id && data.assistant_message) {
@@ -908,6 +988,83 @@ const ChatPage: React.FC = () => {
}
};
// 文件上传:读取文本类文件内容作为附件上下文
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text'];
files.forEach(file => {
const ext = (file.name.split('.').pop() || '').toLowerCase();
const isText = textExts.includes(ext) || file.type.startsWith('text/');
if (isText && file.size <= 200 * 1024) {
const reader = new FileReader();
reader.onload = () => {
const content = String(reader.result || '');
setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]);
};
reader.readAsText(file);
} else {
setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]);
}
});
e.target.value = '';
};
// 语音输入(浏览器 Web Speech API,支持中文)
const handleMicClick = () => {
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SR) {
alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。');
return;
}
if (recording) {
recognitionRef.current?.stop();
return;
}
const rec = new SR();
recognitionRef.current = rec;
rec.lang = 'zh-CN';
rec.interimResults = true;
rec.continuous = false;
rec.onresult = (ev: any) => {
let text = '';
for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
setInputValue(prev => (prev ? prev + text : text));
};
rec.onend = () => { setRecording(false); recognitionRef.current = null; };
rec.onerror = () => { setRecording(false); recognitionRef.current = null; };
setRecording(true);
rec.start();
};
// 语音合成(朗读回复,浏览器 TTS)
const speakText = (text: string) => {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
alert('当前浏览器不支持朗读。');
return;
}
const synth = window.speechSynthesis;
synth.cancel();
const plain = text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#>*`_~\-]+/g, ' ')
.replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const u = new SpeechSynthesisUtterance(plain);
u.lang = 'zh-CN';
u.rate = 1;
synth.speak(u);
};
const handleSend = () => {
const content = inputValue.trim();
if (!content || sending) return;
setInputValue('');
sendText(content);
};
// Handle key down
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -1064,18 +1221,34 @@ const ChatPage: React.FC = () => {
</p>
</div>
) : (
messages.map(msg => {
// Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
messages.map(msg => {
// Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
// 模糊创作意图确认卡片
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{clarify ? (
<ClarifyCard
data={clarify}
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
/>
) : msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
{!clarify && msg.role === 'assistant' && msg.content && (
<button
className="msg-tts-btn"
title="朗读回复"
onClick={() => speakText(msg.content)}
>
<IconVolume />
</button>
)}
{/* Skill usage tags */}
{msg.role === 'assistant' && usedSkills.length > 0 && (
<div className="evo-skill-tags-row">
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
<div className="dropdown-item disabled">暂无可用技能</div>
)}
</Dropdown>
<Dropdown
dropup
closeOnClick={false}
trigger={
<button className="attach-btn" title="MCP 应用">
<IconMcp />
<span>{selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}</span>
</button>
}
>
<div className="attach-dropdown-title">选择 MCP 应用(多选)</div>
{mcps.map((mcp: any) => (
<button key={mcp.id} className={`dropdown-item ${selectedMcpIds.includes(mcp.id) ? 'selected' : ''}`}
onClick={() => { setSelectedMcpIds(prev => prev.includes(mcp.id) ? prev.filter(id => id !== mcp.id) : [...prev, mcp.id]); }}>
<IconMcp />
<span>{mcp.name}</span>
{selectedMcpIds.includes(mcp.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
</button>
))}
{mcps.length > 0 && selectedMcpIds.length > 0 && (
<div className="dropdown-divider" />
)}
{selectedMcpIds.length > 0 && (
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedMcpIds([])}>
清除选择 ({selectedMcpIds.length})
</button>
)}
{mcps.length === 0 && (
<div className="dropdown-item disabled">暂无可用 MCP 应用</div>
)}
</Dropdown>
<button className="attach-btn" title="上传文件" onClick={() => fileInputRef.current?.click()}>
<IconPaperclip />
<span>{attachments.length > 0 ? `文件 (${attachments.length})` : '文件'}</span>
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{attachments.length > 0 && (
<div className="attachment-chips">
{attachments.map((att, idx) => (
<span key={idx} className="attachment-chip">
<IconPaperclip />
<span className="attachment-name" title={att.name}>{att.name}</span>
<button
type="button"
className="attachment-remove"
onClick={() => setAttachments(prev => prev.filter((_, i) => i !== idx))}
title="移除"
>×</button>
</span>
))}
</div>
)}
<div className="input-row">
<textarea
ref={inputRef}
className="chat-textarea"
placeholder={`向 ${selectedModel.name} 发送消息...`}
placeholder={`向 ${selectedModel.name} 发送消息...(可点击 🎤 语音输入)`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={sending}
/>
<button
className={`mic-btn ${recording ? 'recording' : ''}`}
onClick={handleMicClick}
disabled={sending}
title={recording ? '停止录音' : '语音输入'}
>
{recording ? <IconStop /> : <IconMic />}
</button>
<button
className="send-btn"
onClick={handleSend}
+13 -11
View File
@@ -663,6 +663,12 @@ const UploadDocModal: React.FC<{
}
clearInterval(interval);
setProgress(100);
// 上传成功后刷新该知识库的文档列表,无需用户手动刷新页面
try {
await onUpload(kbId, filename.trim(), fileType, fileSize);
} catch (e) {
console.error('刷新文档列表失败:', e);
}
setTimeout(() => {
onClose();
}, 800);
@@ -898,17 +904,13 @@ const KnowledgePage: React.FC = () => {
}
};
// Upload document
const handleUploadDocument = async (baseId: string, filename: string, fileType: string, fileSize: number) => {
const res = await knowledgeAPI.uploadDocument(baseId, {
filename, file_type: fileType, file_size: fileSize,
});
if (res.code === 0) {
await loadDocuments(baseId);
await loadKnowledgeBases();
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
// (显示100%进度条800ms后再关闭)
}
// Upload document - 实际文件上传由 UploadDocModal 内的 fetch 完成,
// 此回调仅用于在上传成功后刷新文档列表与知识库统计,避免重复上传产生重复文档。
const handleUploadDocument = async (baseId: string, _filename?: string, _fileType?: string, _fileSize?: number) => {
await loadDocuments(baseId);
await loadKnowledgeBases();
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
// (显示100%进度条800ms后再关闭)
};
// Delete document
+87 -96
View File
@@ -173,7 +173,7 @@ function renderAppIconLarge(app: MarketApp): React.ReactNode {
const typeLabels: Record<AppType, string> = {
skill: 'Skills工具',
agent: '智能Agent',
mcp: 'MCP协议应用',
mcp: 'MCP应用',
};
const typeIcons: Record<AppType, React.ReactNode> = {
@@ -1158,122 +1158,109 @@ const mcpClient = new MCPClient({
);
};
// ===== Agent Platform Connect Modal (修改4) =====
// ===== Agent 单接入 Modal(修改8:以单个Agent方式接入,含名称/介绍) =====
interface AgentConnectModalProps {
onClose: () => void;
onConnect: (platformId: string, config: Record<string, string>, price?: number) => void;
onConnect: (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => void;
}
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [platform, setPlatform] = useState<string>('dify');
const [name, setName] = useState('');
const [intro, setIntro] = useState('');
const [apiKey, setApiKey] = useState('');
const [endpoint, setEndpoint] = useState('');
const [price, setPrice] = useState<number>(0);
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState<string | null>(null);
const handleConnect = () => {
if (!selectedPlatform || !apiKey.trim()) return;
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
setConnecting(true);
setTimeout(() => {
setConnecting(false);
setConnected(selectedPlatform);
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price);
}, 1500);
onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
}, 800);
};
return (
<div className="market-modal-overlay" onClick={onClose}>
<div className="market-modal agent-connect-modal" onClick={e => e.stopPropagation()}>
<div className="market-modal-header">
<h2>对接外部Agent平台</h2>
<h2>接入单个 Agent</h2>
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="market-modal-body">
<p className="hub-import-desc">
将外部平台的Agent接入AIFlow应用市场,实现跨平台的Agent管理与调度。
以单个 Agent 方式接入(例如 Dify 中每个 Agent 都有独立的访问地址与密钥)。请填写该 Agent 的名称、介绍及连接信息。
</p>
{!selectedPlatform ? (
<div className="agent-platforms">
<div className="form-section">
<label className="form-label">所属平台</label>
<select className="form-input" value={platform} onChange={e => setPlatform(e.target.value)}>
{agentPlatforms.map(p => (
<div
key={p.id}
className={`agent-platform-card ${connected === p.id ? 'connected' : ''}`}
onClick={() => { setSelectedPlatform(p.id); setEndpoint(p.id === 'dify' ? 'https://api.dify.ai/v1' : p.id === 'n8n' ? 'https://n8n.example.com/api/v1' : 'https://api.coze.cn/v1'); }}
>
<div className="agent-platform-icon">
<ApiIcon />
</div>
<div className="agent-platform-info">
<strong>{p.name}</strong>
<span>{p.description}</span>
</div>
{connected === p.id ? (
<span className="connected-badge"><CheckCircleIcon /> 已连接</span>
) : (
<span className="connect-indicator">配置</span>
)}
</div>
<option key={p.id} value={p.id}>{p.name}</option>
))}
</div>
) : (
<div className="agent-config-form">
<button className="back-btn" onClick={() => setSelectedPlatform(null)}>
&larr; 返回选择
</button>
<div className="form-section">
<label className="form-label">平台</label>
<input type="text" className="form-input" value={agentPlatforms.find(p => p.id === selectedPlatform)?.name || ''} disabled />
</div>
<div className="form-section">
<label className="form-label">API Key <span className="required">*</span></label>
<input
type="password"
className="form-input"
placeholder="请输入API Key"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">API Endpoint</label>
<input
type="text"
className="form-input"
placeholder="https://..."
value={endpoint}
onChange={e => setEndpoint(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">定价(元)</label>
<input
type="number"
min="0"
step="0.01"
className="form-input"
placeholder="0 为免费"
value={price}
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
/>
<span className="hub-price-hint">用户对接的Agent:是否收费由您决定;管理员导入默认免费且审核通过。</span>
</div>
<button
className="form-submit-btn"
onClick={handleConnect}
disabled={!apiKey.trim() || connecting}
>
{connecting ? '连接验证中...' : '连接并同步Agent'}
</button>
{connecting && (
<div className="hub-importing-status">
<div className="audit-spinner small" />
<span>正在验证连接并同步Agent列表...</span>
</div>
)}
</div>
)}
</select>
</div>
<div className="form-section">
<label className="form-label">Agent 名称 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="例如:我的客服 Agent"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">Agent 介绍</label>
<textarea
className="form-input"
rows={3}
placeholder="简要描述该 Agent 的能力与用途"
value={intro}
onChange={e => setIntro(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">API Endpoint(该 Agent 专属地址)<span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="https://..."
value={endpoint}
onChange={e => setEndpoint(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">API Key <span className="required">*</span></label>
<input
type="password"
className="form-input"
placeholder="请输入该 Agent 的 API Key"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">定价(元)</label>
<input
type="number"
min="0"
step="0.01"
className="form-input"
placeholder="0 为免费"
value={price}
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
/>
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
</div>
<button
className="form-submit-btn"
onClick={handleConnect}
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting}
>
{connecting ? '接入中...' : '接入 Agent'}
</button>
</div>
</div>
</div>
@@ -1344,7 +1331,7 @@ const MarketPage: React.FC = () => {
{ key: 'all', label: '全部' },
{ key: 'skill', label: 'Skills工具' },
{ key: 'agent', label: '智能Agent' },
{ key: 'mcp', label: 'MCP协议应用' },
{ key: 'mcp', label: 'MCP应用' },
];
// Handle create tool - 调用后端 API 创建应用
@@ -1388,20 +1375,24 @@ const MarketPage: React.FC = () => {
setShowHubImport(null);
};
// Handle agent connect - 调用后端 API 创建应用
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number) => {
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => {
const platform = agentPlatforms.find(p => p.id === platformId);
if (!platform) return;
const finalName = (name && name.trim()) ? name.trim() : `${platform?.name || '外部'} Agent`;
const finalDesc = (description && description.trim())
? description.trim()
: `从 ${platform?.name || '外部平台'} 对接导入的智能Agent`;
try {
await marketAPI.createApp({
name: `${platform.name} Agent`,
name: finalName,
type: 'agent',
description: `从 ${platform.name} 对接导入的智能Agent`,
description: finalDesc,
price: price || 0,
is_member_free: (price || 0) === 0,
join_promotion: false,
source: 'user',
agent_endpoint: config.endpoint || '',
api_key: config.apiKey || '',
});
// 重新加载列表
loadApps();
@@ -478,3 +478,123 @@
font-size: var(--text-xl);
}
}
/* ===== Media Generation Stats ===== */
.tkd-media-section {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xs);
padding: var(--space-xl);
margin-bottom: var(--space-3xl);
}
.tkd-media-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
margin-top: var(--space-lg);
}
.tkd-media-sum-card {
display: flex;
align-items: center;
gap: var(--space-lg);
padding: var(--space-xl);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.tkd-media-sum--image {
background: linear-gradient(135deg, rgba(33,155,94,0.10), rgba(33,155,94,0.02));
border-color: rgba(33,155,94,0.25);
}
.tkd-media-sum--video {
background: linear-gradient(135deg, rgba(217,56,58,0.10), rgba(217,56,58,0.02));
border-color: rgba(217,56,58,0.25);
}
.tkd-media-sum-icon {
font-size: 28px;
line-height: 1;
}
.tkd-media-sum-value {
display: block;
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-text-primary);
line-height: 1.1;
}
.tkd-media-sum-label {
display: block;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-top: 2px;
}
.tkd-media-list {
margin-top: var(--space-lg);
display: flex;
flex-direction: column;
gap: 2px;
}
.tkd-media-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.tkd-media-item:hover {
background: var(--color-bg-tertiary);
}
.tkd-media-item-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 14px;
flex-shrink: 0;
}
.tkd-media-item-name {
flex: 1;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tkd-media-item-count {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: 600;
}
.tkd-media-item-tokens {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
min-width: 110px;
text-align: right;
}
@media (max-width: 768px) {
.tkd-media-summary {
grid-template-columns: 1fr;
}
.tkd-media-item-tokens {
min-width: 80px;
}
}
@@ -131,6 +131,35 @@ function getTodayAppCount(logs: TokenUsageLog[], serverTime?: string): number {
return apps.size;
}
// 判断模型是否为媒体(图像/视频)生成模型
function getMediaType(modelName?: string): 'image' | 'video' | null {
const n = (modelName || '').toLowerCase();
if (n.includes('video')) return 'video';
if (n.includes('image')) return 'image';
return null;
}
// 汇总媒体(图像/视频)生成使用情况:生成次数、消耗 Token、按模型分组
function getMediaStats(logs: TokenUsageLog[]) {
let imageCount = 0;
let videoCount = 0;
let imageTokens = 0;
let videoTokens = 0;
const byModel = new Map<string, { type: 'image' | 'video'; name: string; count: number; tokens: number }>();
logs.forEach((l) => {
const type = getMediaType(l.modelName);
if (!type) return;
if (type === 'image') { imageCount += 1; imageTokens += l.totalTokens || 0; }
else { videoCount += 1; videoTokens += l.totalTokens || 0; }
const cur = byModel.get(l.modelName || '未知模型') || { type, name: l.modelName || '未知模型', count: 0, tokens: 0 };
cur.count += 1;
cur.tokens += l.totalTokens || 0;
byModel.set(l.modelName || '未知模型', cur);
});
const models = Array.from(byModel.values()).sort((a, b) => b.count - a.count);
return { imageCount, videoCount, imageTokens, videoTokens, models };
}
const TokenDashboardPage: React.FC = () => {
const navigate = useNavigate();
const [totalConsumed, setTotalConsumed] = useState(0);
@@ -257,6 +286,7 @@ const TokenDashboardPage: React.FC = () => {
const topModel = getTopModel(logs);
const topApps = getTopApps(logs);
const todayAppCount = getTodayAppCount(logs, serverTime);
const mediaStats = getMediaStats(logs);
const lowBalanceThreshold = 200000;
const isLowBalance = balance < lowBalanceThreshold;
@@ -361,7 +391,22 @@ const TokenDashboardPage: React.FC = () => {
<IconModel />
</div>
<div className="tkd-rank-model-body">
<span className="tkd-rank-model-name">{topModel.name || '-'}</span>
<span className="tkd-rank-model-name">
{topModel.name || '-'}
{topModel.name && getMediaType(topModel.name) && (
<span className="tkd-media-tag" style={{
marginLeft: 8,
padding: '2px 8px',
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
color: '#fff',
background: getMediaType(topModel.name) === 'video' ? '#D9383A' : '#219B5E',
}}>
{getMediaType(topModel.name) === 'video' ? '🎬 视频' : '🖼️ 图像'}
</span>
)}
</span>
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
</div>
</div>
@@ -399,6 +444,43 @@ const TokenDashboardPage: React.FC = () => {
</div>
</div>
{/* Media Generation Stats */}
{mediaStats.models.length > 0 && (
<div className="tkd-media-section">
<h3 className="tkd-table-title">媒体生成统计(图像 / 视频)</h3>
<div className="tkd-media-summary">
<div className="tkd-media-sum-card tkd-media-sum--image">
<span className="tkd-media-sum-icon">🖼️</span>
<div>
<span className="tkd-media-sum-value">{mediaStats.imageCount}</span>
<span className="tkd-media-sum-label">图片生成次数</span>
</div>
</div>
<div className="tkd-media-sum-card tkd-media-sum--video">
<span className="tkd-media-sum-icon">🎬</span>
<div>
<span className="tkd-media-sum-value">{mediaStats.videoCount}</span>
<span className="tkd-media-sum-label">视频生成次数</span>
</div>
</div>
</div>
<div className="tkd-media-list">
{mediaStats.models.map((m) => (
<div key={m.name} className="tkd-media-item">
<span className="tkd-media-item-badge" style={{
background: m.type === 'video' ? '#D9383A' : '#219B5E',
}}>
{m.type === 'video' ? '🎬' : '🖼️'}
</span>
<span className="tkd-media-item-name">{m.name}</span>
<span className="tkd-media-item-count">{m.count} 次</span>
<span className="tkd-media-item-tokens">{formatNumber(m.tokens)} Tokens</span>
</div>
))}
</div>
</div>
)}
{/* Detail Table */}
<div className="tkd-table-wrapper">
<h3 className="tkd-table-title">Token 消耗明细</h3>
@@ -421,6 +503,20 @@ const TokenDashboardPage: React.FC = () => {
<td className="tkd-col-time">{formatTime(log.createdAt)}</td>
<td>
<span className="tkd-model-badge">{log.modelName}</span>
{getMediaType(log.modelName) && (
<span className="tkd-media-tag" style={{
marginLeft: 8,
padding: '1px 7px',
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: getMediaType(log.modelName) === 'video' ? '#D9383A' : '#219B5E',
verticalAlign: 'middle',
}}>
{getMediaType(log.modelName) === 'video' ? '🎬 视频' : '🖼️ 图像'}
</span>
)}
</td>
<td>{log.appName || '-'}</td>
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>