前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key)
This commit is contained in:
1 parent
463824480b
commit
bc2899ed09
11 files changed
+898
-107
No files matched your search
@@ -66,6 +66,50 @@ def detect_media_intent(text: str) -> str | None:
|
|||||||
return "audio"
|
return "audio"
|
||||||
return None
|
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 timezone (UTC+8) for consistent time display
|
||||||
CST_TZ = timezone(timedelta(hours=8))
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
@@ -243,6 +287,8 @@ class SendMessageRequest(BaseModel):
|
|||||||
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
|
||||||
|
attachments: Optional[list] = None
|
||||||
use_tools: Optional[bool] = False
|
use_tools: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
@@ -374,7 +420,36 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
|
|||||||
return results
|
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格式内容, 提取结构化信息。
|
||||||
SKILL.md格式:
|
SKILL.md格式:
|
||||||
@@ -651,6 +726,7 @@ def _build_system_prompt(
|
|||||||
agents: list,
|
agents: list,
|
||||||
kb_context: str,
|
kb_context: str,
|
||||||
user_content: str,
|
user_content: str,
|
||||||
|
mcp: list = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""
|
"""
|
||||||
动态组装system prompt + function definitions。
|
动态组装system prompt + function definitions。
|
||||||
@@ -736,6 +812,12 @@ def _build_system_prompt(
|
|||||||
if kb_context:
|
if kb_context:
|
||||||
system_parts.append(f"参考以下知识库内容回答用户问题:\n{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)
|
system_prompt = "\n".join(system_parts)
|
||||||
return system_prompt, function_definitions
|
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
|
context["agent_ids"] = first_tool_msg.agent_ids
|
||||||
if first_tool_msg.knowledge_ids:
|
if first_tool_msg.knowledge_ids:
|
||||||
context["knowledge_base_ids"] = 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
|
return context
|
||||||
|
|
||||||
@@ -1068,33 +1152,88 @@ async def send_message(
|
|||||||
effective_skill_ids = req.skill_ids or []
|
effective_skill_ids = req.skill_ids or []
|
||||||
effective_agent_ids = req.agent_ids or []
|
effective_agent_ids = req.agent_ids or []
|
||||||
effective_kb_ids = req.knowledge_base_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
|
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)
|
prev_context = _get_conversation_context(db, conv_id)
|
||||||
if prev_context:
|
if prev_context:
|
||||||
effective_skill_ids = prev_context.get("skill_ids", [])
|
effective_skill_ids = prev_context.get("skill_ids", [])
|
||||||
effective_agent_ids = prev_context.get("agent_ids", [])
|
effective_agent_ids = prev_context.get("agent_ids", [])
|
||||||
effective_kb_ids = prev_context.get("knowledge_base_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
|
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(
|
user_msg = Message(
|
||||||
id=gen_uuid(),
|
id=gen_uuid(),
|
||||||
conversation_id=conv_id,
|
conversation_id=conv_id,
|
||||||
role="user",
|
role="user",
|
||||||
content=req.content,
|
content=req.content + attachment_block,
|
||||||
token_used=len(req.content) // 2,
|
token_used=len(req.content) // 2,
|
||||||
model_id=req.model_id or conv.model_id,
|
model_id=req.model_id or conv.model_id,
|
||||||
skill_ids=req.skill_ids if req.skill_ids else None,
|
skill_ids=req.skill_ids if req.skill_ids else None,
|
||||||
agent_ids=req.agent_ids if req.agent_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,
|
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(),
|
created_at=cst_now(),
|
||||||
)
|
)
|
||||||
db.add(user_msg)
|
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
|
user_id = current_user.id
|
||||||
model_id = req.model_id or conv.model_id
|
model_id = req.model_id or conv.model_id
|
||||||
@@ -1140,6 +1279,7 @@ async def send_message(
|
|||||||
# Stage A+B: 解析Skills和Agents
|
# Stage A+B: 解析Skills和Agents
|
||||||
skills = _resolve_market_skills(db, effective_skill_ids, user_id)
|
skills = _resolve_market_skills(db, effective_skill_ids, user_id)
|
||||||
agents = _resolve_market_agents(db, effective_agent_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
|
# Stage B.1: Agent可能自带skill_ids和kb_ids
|
||||||
agent_extra_skill_ids = []
|
agent_extra_skill_ids = []
|
||||||
@@ -1171,6 +1311,7 @@ async def send_message(
|
|||||||
agents=agents,
|
agents=agents,
|
||||||
kb_context=kb_context,
|
kb_context=kb_context,
|
||||||
user_content=req.content,
|
user_content=req.content,
|
||||||
|
mcp=mcps,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 构建消息列表
|
# 构建消息列表
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class UploadAppRequest(BaseModel):
|
|||||||
is_member_free: Optional[bool] = False
|
is_member_free: Optional[bool] = False
|
||||||
join_promotion: Optional[bool] = False
|
join_promotion: Optional[bool] = False
|
||||||
agent_endpoint: Optional[str] = None
|
agent_endpoint: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PurchaseAppRequest(BaseModel):
|
class PurchaseAppRequest(BaseModel):
|
||||||
@@ -119,6 +120,8 @@ def list_apps(
|
|||||||
|
|
||||||
result = []
|
result = []
|
||||||
for app in apps:
|
for app in apps:
|
||||||
|
# 用户自己创建/导入的应用应始终视为已安装(避免安装记录缺失导致显示未安装)
|
||||||
|
is_user_owned = bool(user_id) and app.source == "user" and app.publisher_id == user_id
|
||||||
item = {
|
item = {
|
||||||
"id": app.id,
|
"id": app.id,
|
||||||
"name": app.name,
|
"name": app.name,
|
||||||
@@ -136,7 +139,7 @@ def list_apps(
|
|||||||
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
||||||
"review_count": app.review_count or 0,
|
"review_count": app.review_count or 0,
|
||||||
"created_at": str(app.created_at) if app.created_at else None,
|
"created_at": str(app.created_at) if app.created_at else None,
|
||||||
"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)
|
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,
|
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,
|
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
||||||
agent_endpoint=req.agent_endpoint,
|
agent_endpoint=req.agent_endpoint,
|
||||||
|
agent_api_key=req.api_key,
|
||||||
status="published", # 用户创建的应用直接发布
|
status="published", # 用户创建的应用直接发布
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
updated_at=datetime.now(),
|
updated_at=datetime.now(),
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ class Message(Base):
|
|||||||
knowledge_ids = Column(JSON, default=None)
|
knowledge_ids = Column(JSON, default=None)
|
||||||
agent_ids = Column(JSON, default=None)
|
agent_ids = Column(JSON, default=None)
|
||||||
skill_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="该轮对话调用的进化技能名列表")
|
used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ class MarketApp(Base):
|
|||||||
skill_content = Column(Text, default=None)
|
skill_content = Column(Text, default=None)
|
||||||
mcp_transport = Column(String(20), default=None)
|
mcp_transport = Column(String(20), default=None)
|
||||||
agent_endpoint = Column(String(500), 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)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|||||||
@@ -467,9 +467,14 @@ export const notificationAPI = {
|
|||||||
|
|
||||||
export const assetsAPI = {
|
export const assetsAPI = {
|
||||||
|
|
||||||
getAssets: (category?: string) =>
|
getAssets: (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||||
|
const qs: string[] = [];
|
||||||
get<any[]>(`/assets/${category ? `?category=${category}` : ''}`),
|
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),
|
createAsset: (data: any) => post<any>('/assets/', data),
|
||||||
|
|
||||||
|
|||||||
@@ -392,24 +392,43 @@ const AssetsPage: React.FC = () => {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const navigate = useNavigate();
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const catParam = category && category !== 'all' ? category : undefined;
|
let all: any[] = [];
|
||||||
const res = await assetsAPI.getAssets(catParam);
|
let page = 1;
|
||||||
// getAssets 返回分页格式 {total, page, page_size, items}
|
const pageSize = 100;
|
||||||
const rawItems = (res.data as any)?.items || res.data || [];
|
for (;;) {
|
||||||
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
|
const res = await assetsAPI.getAssets({ page, page_size: pageSize });
|
||||||
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
|
const data = (res.data as any) || {};
|
||||||
...item,
|
const items: any[] = data.items || [];
|
||||||
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
|
const total: number = data.total || 0;
|
||||||
fileType: item.fileType || item.file_type || 'other',
|
all = all.concat(items);
|
||||||
}));
|
if (items.length === 0 || all.length >= total) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
const normalized = all.map(normalizeAsset);
|
||||||
// 按id去重
|
// 按id去重
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped = normalizedAssets.filter(a => {
|
const deduped = normalized.filter(a => {
|
||||||
if (seen.has(a.id)) return false;
|
if (seen.has(a.id)) return false;
|
||||||
seen.add(a.id);
|
seen.add(a.id);
|
||||||
return true;
|
return true;
|
||||||
@@ -451,13 +470,9 @@ const AssetsPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
fetchAssets();
|
fetchAllAssets();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
|
|
||||||
}, [activeCategory]);
|
|
||||||
|
|
||||||
// Filter by sub-category for files
|
// Filter by sub-category for files
|
||||||
const filtered = (() => {
|
const filtered = (() => {
|
||||||
// 如果是文件分类,先确保只显示文件类资产
|
// 如果是文件分类,先确保只显示文件类资产
|
||||||
@@ -640,7 +655,7 @@ const AssetsPage: React.FC = () => {
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreateAssetModal
|
<CreateAssetModal
|
||||||
onClose={() => setShowCreate(false)}
|
onClose={() => setShowCreate(false)}
|
||||||
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
|
onCreated={() => fetchAllAssets()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1257,3 +1257,175 @@
|
|||||||
max-width: 92vw;
|
max-width: 92vw;
|
||||||
max-height: 86vh;
|
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);
|
||||||
|
}
|
||||||
@@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
|||||||
return <div className="markdown-body">{nodes}</div>;
|
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 {
|
function escapeHtml(text: string): string {
|
||||||
return text
|
return text
|
||||||
@@ -358,6 +388,37 @@ const IconSkill = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const IconModel = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
<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"/>
|
<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 [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
||||||
const [agents, setAgents] = useState<any[]>([]);
|
const [agents, setAgents] = useState<any[]>([]);
|
||||||
const [skills, setSkills] = useState<any[]>([]);
|
const [skills, setSkills] = useState<any[]>([]);
|
||||||
|
const [mcps, setMcps] = useState<any[]>([]);
|
||||||
// 多选组合模式
|
// 多选组合模式
|
||||||
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
||||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||||
const [selectedKbIds, setSelectedKbIds] = 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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(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));
|
setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||||
}).catch(() => {});
|
}).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
|
// Determine selected conversation
|
||||||
@@ -822,10 +897,9 @@ const ChatPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle send message - CALLS REAL API
|
// Handle send message - CALLS REAL API
|
||||||
const handleSend = async () => {
|
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
|
||||||
if (!inputValue.trim() || sending) return;
|
const sendText = async (content: string) => {
|
||||||
const content = inputValue.trim();
|
if (!content.trim() || sending || !selectedConversation) return;
|
||||||
setInputValue('');
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
// Add user message optimistically
|
// Add user message optimistically
|
||||||
@@ -846,7 +920,11 @@ const ChatPage: React.FC = () => {
|
|||||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||||||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : 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);
|
const data = unwrapData(res);
|
||||||
@@ -886,6 +964,8 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
// Refresh conversations to update lastMessage
|
// Refresh conversations to update lastMessage
|
||||||
loadConversations();
|
loadConversations();
|
||||||
|
// 发送后清空本次附件(选择状态仍保留)
|
||||||
|
setAttachments([]);
|
||||||
|
|
||||||
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
||||||
if (selectedConversation?.id && data.assistant_message) {
|
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
|
// Handle key down
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -1067,15 +1224,31 @@ const ChatPage: React.FC = () => {
|
|||||||
messages.map(msg => {
|
messages.map(msg => {
|
||||||
// Real used_skills from API response
|
// Real used_skills from API response
|
||||||
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
||||||
|
// 模糊创作意图确认卡片
|
||||||
|
const 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}`}>
|
||||||
<div className={`message-bubble ${msg.role}`}>
|
<div className={`message-bubble ${msg.role}`}>
|
||||||
{msg.role === 'assistant' ? (
|
{clarify ? (
|
||||||
|
<ClarifyCard
|
||||||
|
data={clarify}
|
||||||
|
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
|
||||||
|
/>
|
||||||
|
) : msg.role === 'assistant' ? (
|
||||||
<MarkdownContent content={msg.content} />
|
<MarkdownContent content={msg.content} />
|
||||||
) : (
|
) : (
|
||||||
<span>{msg.content}</span>
|
<span>{msg.content}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!clarify && msg.role === 'assistant' && msg.content && (
|
||||||
|
<button
|
||||||
|
className="msg-tts-btn"
|
||||||
|
title="朗读回复"
|
||||||
|
onClick={() => speakText(msg.content)}
|
||||||
|
>
|
||||||
|
<IconVolume />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Skill usage tags */}
|
{/* Skill usage tags */}
|
||||||
{msg.role === 'assistant' && usedSkills.length > 0 && (
|
{msg.role === 'assistant' && usedSkills.length > 0 && (
|
||||||
<div className="evo-skill-tags-row">
|
<div className="evo-skill-tags-row">
|
||||||
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
|
|||||||
<div className="dropdown-item disabled">暂无可用技能</div>
|
<div className="dropdown-item disabled">暂无可用技能</div>
|
||||||
)}
|
)}
|
||||||
</Dropdown>
|
</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>
|
</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">
|
<div className="input-row">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="chat-textarea"
|
className="chat-textarea"
|
||||||
placeholder={`向 ${selectedModel.name} 发送消息...`}
|
placeholder={`向 ${selectedModel.name} 发送消息...(可点击 🎤 语音输入)`}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
className={`mic-btn ${recording ? 'recording' : ''}`}
|
||||||
|
onClick={handleMicClick}
|
||||||
|
disabled={sending}
|
||||||
|
title={recording ? '停止录音' : '语音输入'}
|
||||||
|
>
|
||||||
|
{recording ? <IconStop /> : <IconMic />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="send-btn"
|
className="send-btn"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
|
|||||||
@@ -663,6 +663,12 @@ const UploadDocModal: React.FC<{
|
|||||||
}
|
}
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
|
// 上传成功后刷新该知识库的文档列表,无需用户手动刷新页面
|
||||||
|
try {
|
||||||
|
await onUpload(kbId, filename.trim(), fileType, fileSize);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('刷新文档列表失败:', e);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
}, 800);
|
}, 800);
|
||||||
@@ -898,17 +904,13 @@ const KnowledgePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upload document
|
// Upload document - 实际文件上传由 UploadDocModal 内的 fetch 完成,
|
||||||
const handleUploadDocument = async (baseId: string, filename: string, fileType: string, fileSize: number) => {
|
// 此回调仅用于在上传成功后刷新文档列表与知识库统计,避免重复上传产生重复文档。
|
||||||
const res = await knowledgeAPI.uploadDocument(baseId, {
|
const handleUploadDocument = async (baseId: string, _filename?: string, _fileType?: string, _fileSize?: number) => {
|
||||||
filename, file_type: fileType, file_size: fileSize,
|
|
||||||
});
|
|
||||||
if (res.code === 0) {
|
|
||||||
await loadDocuments(baseId);
|
await loadDocuments(baseId);
|
||||||
await loadKnowledgeBases();
|
await loadKnowledgeBases();
|
||||||
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
|
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
|
||||||
// (显示100%进度条800ms后再关闭)
|
// (显示100%进度条800ms后再关闭)
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete document
|
// Delete document
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function renderAppIconLarge(app: MarketApp): React.ReactNode {
|
|||||||
const typeLabels: Record<AppType, string> = {
|
const typeLabels: Record<AppType, string> = {
|
||||||
skill: 'Skills工具',
|
skill: 'Skills工具',
|
||||||
agent: '智能Agent',
|
agent: '智能Agent',
|
||||||
mcp: 'MCP协议应用',
|
mcp: 'MCP应用',
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeIcons: Record<AppType, React.ReactNode> = {
|
const typeIcons: Record<AppType, React.ReactNode> = {
|
||||||
@@ -1158,86 +1158,71 @@ const mcpClient = new MCPClient({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Agent Platform Connect Modal (修改4) =====
|
// ===== Agent 单接入 Modal(修改8:以单个Agent方式接入,含名称/介绍) =====
|
||||||
interface AgentConnectModalProps {
|
interface AgentConnectModalProps {
|
||||||
onClose: () => void;
|
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 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 [apiKey, setApiKey] = useState('');
|
||||||
const [endpoint, setEndpoint] = useState('');
|
const [endpoint, setEndpoint] = useState('');
|
||||||
const [price, setPrice] = useState<number>(0);
|
const [price, setPrice] = useState<number>(0);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
const [connected, setConnected] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleConnect = () => {
|
const handleConnect = () => {
|
||||||
if (!selectedPlatform || !apiKey.trim()) return;
|
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(selectedPlatform);
|
onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
||||||
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price);
|
}, 800);
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="market-modal-overlay" onClick={onClose}>
|
<div className="market-modal-overlay" onClick={onClose}>
|
||||||
<div className="market-modal agent-connect-modal" onClick={e => e.stopPropagation()}>
|
<div className="market-modal agent-connect-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="market-modal-header">
|
<div className="market-modal-header">
|
||||||
<h2>对接外部Agent平台</h2>
|
<h2>接入单个 Agent</h2>
|
||||||
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="market-modal-body">
|
<div className="market-modal-body">
|
||||||
<p className="hub-import-desc">
|
<p className="hub-import-desc">
|
||||||
将外部平台的Agent接入AIFlow应用市场,实现跨平台的Agent管理与调度。
|
以单个 Agent 方式接入(例如 Dify 中每个 Agent 都有独立的访问地址与密钥)。请填写该 Agent 的名称、介绍及连接信息。
|
||||||
</p>
|
</p>
|
||||||
|
<div className="form-section">
|
||||||
{!selectedPlatform ? (
|
<label className="form-label">所属平台</label>
|
||||||
<div className="agent-platforms">
|
<select className="form-input" value={platform} onChange={e => setPlatform(e.target.value)}>
|
||||||
{agentPlatforms.map(p => (
|
{agentPlatforms.map(p => (
|
||||||
<div
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
) : (
|
|
||||||
<div className="agent-config-form">
|
|
||||||
<button className="back-btn" onClick={() => setSelectedPlatform(null)}>
|
|
||||||
← 返回选择
|
|
||||||
</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>
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<label className="form-label">API Key <span className="required">*</span></label>
|
<label className="form-label">Agent 名称 <span className="required">*</span></label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="请输入API Key"
|
placeholder="例如:我的客服 Agent"
|
||||||
value={apiKey}
|
value={name}
|
||||||
onChange={e => setApiKey(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<label className="form-label">API Endpoint</label>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -1246,6 +1231,16 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
onChange={e => setEndpoint(e.target.value)}
|
onChange={e => setEndpoint(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="form-section">
|
||||||
<label className="form-label">定价(元)</label>
|
<label className="form-label">定价(元)</label>
|
||||||
<input
|
<input
|
||||||
@@ -1257,23 +1252,15 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
value={price}
|
value={price}
|
||||||
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
||||||
/>
|
/>
|
||||||
<span className="hub-price-hint">用户对接的Agent:是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="form-submit-btn"
|
className="form-submit-btn"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
disabled={!apiKey.trim() || connecting}
|
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting}
|
||||||
>
|
>
|
||||||
{connecting ? '连接验证中...' : '连接并同步Agent'}
|
{connecting ? '接入中...' : '接入 Agent'}
|
||||||
</button>
|
</button>
|
||||||
{connecting && (
|
|
||||||
<div className="hub-importing-status">
|
|
||||||
<div className="audit-spinner small" />
|
|
||||||
<span>正在验证连接并同步Agent列表...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1344,7 +1331,7 @@ const MarketPage: React.FC = () => {
|
|||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
{ key: 'skill', label: 'Skills工具' },
|
{ key: 'skill', label: 'Skills工具' },
|
||||||
{ key: 'agent', label: '智能Agent' },
|
{ key: 'agent', label: '智能Agent' },
|
||||||
{ key: 'mcp', label: 'MCP协议应用' },
|
{ key: 'mcp', label: 'MCP应用' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Handle create tool - 调用后端 API 创建应用
|
// Handle create tool - 调用后端 API 创建应用
|
||||||
@@ -1388,20 +1375,24 @@ const MarketPage: React.FC = () => {
|
|||||||
setShowHubImport(null);
|
setShowHubImport(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle agent connect - 调用后端 API 创建应用
|
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
|
||||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number) => {
|
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => {
|
||||||
const platform = agentPlatforms.find(p => p.id === platformId);
|
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 {
|
try {
|
||||||
await marketAPI.createApp({
|
await marketAPI.createApp({
|
||||||
name: `${platform.name} Agent`,
|
name: finalName,
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
description: `从 ${platform.name} 对接导入的智能Agent`,
|
description: finalDesc,
|
||||||
price: price || 0,
|
price: price || 0,
|
||||||
is_member_free: (price || 0) === 0,
|
is_member_free: (price || 0) === 0,
|
||||||
join_promotion: false,
|
join_promotion: false,
|
||||||
source: 'user',
|
source: 'user',
|
||||||
agent_endpoint: config.endpoint || '',
|
agent_endpoint: config.endpoint || '',
|
||||||
|
api_key: config.apiKey || '',
|
||||||
});
|
});
|
||||||
// 重新加载列表
|
// 重新加载列表
|
||||||
loadApps();
|
loadApps();
|
||||||
|
|||||||
@@ -478,3 +478,123 @@
|
|||||||
font-size: var(--text-xl);
|
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;
|
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 TokenDashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||||
@@ -257,6 +286,7 @@ const TokenDashboardPage: React.FC = () => {
|
|||||||
const topModel = getTopModel(logs);
|
const topModel = getTopModel(logs);
|
||||||
const topApps = getTopApps(logs);
|
const topApps = getTopApps(logs);
|
||||||
const todayAppCount = getTodayAppCount(logs, serverTime);
|
const todayAppCount = getTodayAppCount(logs, serverTime);
|
||||||
|
const mediaStats = getMediaStats(logs);
|
||||||
const lowBalanceThreshold = 200000;
|
const lowBalanceThreshold = 200000;
|
||||||
const isLowBalance = balance < lowBalanceThreshold;
|
const isLowBalance = balance < lowBalanceThreshold;
|
||||||
|
|
||||||
@@ -361,7 +391,22 @@ const TokenDashboardPage: React.FC = () => {
|
|||||||
<IconModel />
|
<IconModel />
|
||||||
</div>
|
</div>
|
||||||
<div className="tkd-rank-model-body">
|
<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>
|
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +444,43 @@ const TokenDashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Detail Table */}
|
||||||
<div className="tkd-table-wrapper">
|
<div className="tkd-table-wrapper">
|
||||||
<h3 className="tkd-table-title">Token 消耗明细</h3>
|
<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 className="tkd-col-time">{formatTime(log.createdAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="tkd-model-badge">{log.modelName}</span>
|
<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>
|
||||||
<td>{log.appName || '-'}</td>
|
<td>{log.appName || '-'}</td>
|
||||||
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
|
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
|
||||||
|
|||||||
Reference in new issue
Block a user