diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py
index c7e8014..cf29f07 100644
--- a/backend/python/app/api/chat.py
+++ b/backend/python/app/api/chat.py
@@ -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 = "<>" + json.dumps(
+ {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False
+ ) + "<>"
+ 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,
)
# 构建消息列表
diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py
index fa6a0b1..20c9142 100644
--- a/backend/python/app/api/market.py
+++ b/backend/python/app/api/market.py
@@ -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(),
diff --git a/backend/python/app/models.py b/backend/python/app/models.py
index bbaa3a3..6399e71 100644
--- a/backend/python/app/models.py
+++ b/backend/python/app/models.py
@@ -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)
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 3e16e6d..882640a 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -467,9 +467,14 @@ export const notificationAPI = {
export const assetsAPI = {
- getAssets: (category?: string) =>
-
- get(`/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(`/assets/${q}`);
+ },
createAsset: (data: any) => post('/assets/', data),
diff --git a/frontend/src/pages/front/AssetsPage.tsx b/frontend/src/pages/front/AssetsPage.tsx
index fd1afae..942d86a 100644
--- a/frontend/src/pages/front/AssetsPage.tsx
+++ b/frontend/src/pages/front/AssetsPage.tsx
@@ -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();
- 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 && (
setShowCreate(false)}
- onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
+ onCreated={() => fetchAllAssets()}
/>
)}
diff --git a/frontend/src/pages/front/ChatPage.css b/frontend/src/pages/front/ChatPage.css
index c6301d0..0ac13ea 100644
--- a/frontend/src/pages/front/ChatPage.css
+++ b/frontend/src/pages/front/ChatPage.css
@@ -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);
+}
diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx
index b2a8da2..7bd0c20 100644
--- a/frontend/src/pages/front/ChatPage.tsx
+++ b/frontend/src/pages/front/ChatPage.tsx
@@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
return {nodes}
;
};
+// 解析后端返回的「模糊创作意图」确认指令:<>{json}<>
+function parseClarify(content: string): { prompt: string; options: any[] } | null {
+ if (!content || typeof content !== 'string') return null;
+ const m = content.match(/^<>([\s\S]+)<>$/);
+ 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 (
+
+
🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?
+
「{data.prompt}」
+
+ {data.options.map((opt: any) => (
+
+ ))}
+
+
+ );
+};
+
function escapeHtml(text: string): string {
return text
@@ -358,6 +388,37 @@ const IconSkill = () => (
);
+const IconMcp = () => (
+
+);
+
+const IconPaperclip = () => (
+
+);
+
+const IconMic = () => (
+
+);
+
+const IconStop = () => (
+
+);
+
+const IconVolume = () => (
+
+);
+
const IconModel = () => (
) : (
- messages.map(msg => {
- // Real used_skills from API response
- const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
- return (
-
-
- {msg.role === 'assistant' ? (
-
- ) : (
- {msg.content}
- )}
-
+ 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 (
+
+
+ {clarify ? (
+ sendText(`${opt.medium}:${clarify.prompt}`)}
+ />
+ ) : msg.role === 'assistant' ? (
+
+ ) : (
+ {msg.content}
+ )}
+
+ {!clarify && msg.role === 'assistant' && msg.content && (
+
+ )}
{/* Skill usage tags */}
{msg.role === 'assistant' && usedSkills.length > 0 && (
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
暂无可用技能
)}
+
+
+
+ {selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}
+
+ }
+ >
+ 选择 MCP 应用(多选)
+ {mcps.map((mcp: any) => (
+
+ ))}
+ {mcps.length > 0 && selectedMcpIds.length > 0 && (
+
+ )}
+ {selectedMcpIds.length > 0 && (
+
+ )}
+ {mcps.length === 0 && (
+ 暂无可用 MCP 应用
+ )}
+
+
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, idx) => (
+
+
+ {att.name}
+
+
+ ))}
+
+ )}
+
- {topModel.name || '-'}
+
+ {topModel.name || '-'}
+ {topModel.name && getMediaType(topModel.name) && (
+
+ {getMediaType(topModel.name) === 'video' ? '🎬 视频' : '🖼️ 图像'}
+
+ )}
+
{formatNumber(topModel.total)} Tokens
@@ -399,6 +444,43 @@ const TokenDashboardPage: React.FC = () => {
+ {/* Media Generation Stats */}
+ {mediaStats.models.length > 0 && (
+
+
媒体生成统计(图像 / 视频)
+
+
+
🖼️
+
+ {mediaStats.imageCount}
+ 图片生成次数
+
+
+
+
🎬
+
+ {mediaStats.videoCount}
+ 视频生成次数
+
+
+
+
+ {mediaStats.models.map((m) => (
+
+
+ {m.type === 'video' ? '🎬' : '🖼️'}
+
+ {m.name}
+ {m.count} 次
+ {formatNumber(m.tokens)} Tokens
+
+ ))}
+
+
+ )}
+
{/* Detail Table */}
Token 消耗明细
@@ -421,6 +503,20 @@ const TokenDashboardPage: React.FC = () => {
{formatTime(log.createdAt)} |
{log.modelName}
+ {getMediaType(log.modelName) && (
+
+ {getMediaType(log.modelName) === 'video' ? '🎬 视频' : '🖼️ 图像'}
+
+ )}
|
{log.appName || '-'} |
{formatNumber(log.promptTokens)} |