Compare commits

...
Author SHA1 Message Date
QihuAI Design 3b34ba78b8 feat(agents): Phase3.2 Agent凭据Fernet加密存储 写入加密+调用点解密+返回脱敏+主密钥持久化防竞态 [v2026.07.19.7]
CI / gate (push) Has been cancelled
2026-07-19 15:27:15 +08:00
QihuAI Design 0600c37b9e feat(agents): Phase3.1 工具/MCP权限沙箱(tool_policy) 白名单+预算熔断+每轮上限+后台管理UI [v2026.07.19.6]
CI / gate (push) Has been cancelled
2026-07-19 12:34:40 +08:00
QihuAI Design 2969544d54 feat(observability): Phase2.3 媒体/LLM 可观测面板(后端归因聚合+前端页) 并修复 token_usage_logs trace_id 写入(chat_service/im_llm) [v2026.07.19.5]
CI / gate (push) Has been cancelled
2026-07-19 10:18:14 +08:00
QihuAI Design 491f31cead refactor(context): Phase2 上下文引擎升级 分层记忆+压缩+隔离 [v2026.07.19.4]
CI / gate (push) Has been cancelled
2026-07-19 09:29:57 +08:00
QihuAI Design a7a57f9a03 refactor(observability): Phase2.2 成本归因 trace_id列+token日志写入+LLM/MCP/媒体子span [v2026.07.19.3]
CI / gate (push) Has been cancelled
2026-07-19 08:51:28 +08:00
QihuAI Design 6aefdfa5c2 refactor(observability): Phase2.1 统一可观测基座 trace_id+结构化日志+span计时 [v2026.07.19.2]
CI / gate (push) Has been cancelled
2026-07-19 07:47:25 +08:00
QihuAI Design 1b6a184d27 refactor(chat): split 10070-line monolith into 8 service submodules (Phase1) [v2026.07.19.1]
CI / gate (push) Has been cancelled
2026-07-19 00:59:03 +08:00
QihuAI Design 4167e23e50 ci: add minimal CI gate (pre-push hook + ci_gate.py + dormant Gitea workflow) [v2026.07.18.5]
CI / gate (push) Has been cancelled
2026-07-19 00:01:07 +08:00
QihuAI Design dd10245ad5 test: 前端 vitest 测试基建 (Phase0) — 北京时间渲染回归 4 用例全绿 2026-07-18 23:47:21 +08:00
QihuAI Design 1744ff40ce test: 回归用例 R1/R2/R3 固化三个反复问题 (Phase0) — 9 用例全绿 2026-07-18 23:36:01 +08:00
QihuAI Design 13578ba4c2 test: 后端 pytest 测试基建 (Phase0) — 6 用例全绿 + 部署前备份SOP合规 2026-07-18 23:29:55 +08:00
QihuAI Design 9e9c506ba9 release: v2026.07.18.1 (prod) — Phase0 工程纪律止血:部署前镜像备份SOP + 版本号规范 + 测试基建 2026-07-18 23:19:18 +08:00
QihuAI Design 1ea7ea352f chore: remove accidental .bak file 2026-07-18 13:54:19 +08:00
QihuAI Design d593d8dc42 fix: 显式选中技能/MCP 时优先按工具资料回答 + 媒体任务超时/日志
- _build_system_prompt 末尾增加【最高优先级覆盖规则】:用户明确选择技能/MCP 并询问其介绍/功能/用法时,
  必须只基于该工具资料作答,禁止引用平台知识库中技能市场/MCP/平台能力的通用描述。
- _generate_media_task 增加启动/结束日志,对图像生成加 90s 超时、视频 300s 超时,避免异步任务挂死导致前端一直显示“生成中”。
2026-07-18 13:52:26 +08:00
QihuAI Design 1578d68f95 fix: 修复前台4项问题(MCP列举/微信技能上限/Word资产栏/技能MCP反馈) + 同步容器live状态
- #549 平台MCP列举: 注入全量已启用MCP清单, AI不再凭空编造
- #550 微信Bot技能自动发现上限 15->300
- #551 知识库Word文档同步资产后Word栏可见 + 双向删除同步(assets/knowledge级联软删)
- #552 对话反馈实际使用的技能/MCP(used_mcp字段), 含历史消息序列化
- 同步容器内其他未提交live改动(im_bot_platform/TokenDashboard/WechatBot/ChatPage)
2026-07-18 08:06:37 +08:00
QihuAI Design 7fb7c07f57 fix(admin/skills): 支持删除从市场导入的 MarketApp 技能 2026-07-17 22:25:47 +08:00
QihuAI Design cc35c61fbd fix: 技能市场分类映射缺失+筛选chip激活态配色;聊天显式选中技能时正确介绍技能并精简回复排版 2026-07-17 21:45:30 +08:00
QihuAI Design f8d1c18927 fix(admin): 后台未登录访问 Skills/MCP/Agent 报 Not authenticated\n\n- AdminLayout 增加登录+管理员守卫,无 token 或非 admin 跳 /login\n- api request() 处理 403 Not authenticated/需要管理员权限,清 token 跳登录\n- 根因:FastAPI HTTPBearer 无 token 返回 403 而非 401,前端未处理 403 跳转 2026-07-17 20:46:05 +08:00
QihuAI Design e2369b4915 fix(market): 强化场景分类器解析兜底,避免合法 id 误判为 None 2026-07-17 15:44:58 +08:00
QihuAI Design dc3077b5ae fix(market): 强化场景分类器解析兜底,避免合法 id 误判为 None 2026-07-17 15:37:35 +08:00
QihuAI Design fe051bd9f3 feat(market): 采用元宝岗位/职能分类(14类)替代人群场景 + AI验证期自动归类 2026-07-17 15:22:57 +08:00
QihuAI Design 9bcf15099f feat(market): 技能/MCP 分类改为面向用户的业务场景(人群->场景),AI验证期按场景归类 2026-07-17 14:55:21 +08:00
QihuAI Design 6f3fb400a4 feat(market): skills/mcp 能力分类 + AI验证期自动识别 + 导入自动入座 2026-07-17 12:09:42 +08:00
QihuAI Design 2ae5d9f9b5 fix(skills): 修复导入技能时 SKILL 内容丢失导致无法正常工作
- 前端 handleSubmit/handleCreateApp 发送 skillsContent;handleHubImport 对 skill 把目录 URL 作为 download_url
- 后端 upload_app 对 skill 类型写入 skill_content(手动粘贴或 Hub 抓取)
- 新增 _fetch_skill_content 按目录 URL 抓取真实 SKILL.md 回填(best-effort,仅接受有效 markdown)
- 清理 50 条空壳 skill 记录(skill_content 为空,不可恢复)
- 经 E2E 验证:手动粘贴/Hub 抓取/MCP 重导三条路径均能正确落库
2026-07-17 10:46:47 +08:00
QihuAI Design 47d38ca173 fix(MCP): 修复导入MCP时传输代码丢失导致无法连接的问题
- 前端 MarketPage 提交时随 download_url 发送粘贴的 MCP 代码(handleSubmit+handleCreateApp)
- 后端新增 parse_mcp_config 解析标准 JSON 配置, 提取 url/headers/transport
- upload_app 与 _sync 使用解析器落库 mcp_servers 并自动连通性测试
- _test_mcp_server 连接测试携带 headers 鉴权头
- 优化 MCP 代码输入框占位符为真实配置格式
2026-07-17 09:43:43 +08:00
QihuAI Design 24ed907ba5 feat: 实现MCP工具调用链路(streamable_http) - chat中MCP作为function calling真实调用tools/call 2026-07-16 20:59:42 +08:00
QihuAI Design ab90ac9473 fix: MCP后台删除/禁用同步清理market_apps源头,避免被_sync复活 2026-07-16 20:09:16 +08:00
QihuAI Design 6331e84185 feat: MCP streamable_http connection test + auto-test on import 2026-07-16 18:38:10 +08:00
QihuAI Design feb9865b00 fix: sync MarketApp MCP records to admin MCP Client management 2026-07-16 18:12:07 +08:00
QihuAI Design c9b5f964be feat(frontend): per-platform Streamable HTTP input for MCP Hub import 2026-07-16 17:36:58 +08:00
QihuAI Design e856f186be ui(mcp-hub): MCP导入弹窗复用 skills 优化方案
两个问题:
1. 标题"导入Hub - MCP"偏左未居中 — .market-modal-header h2
   加 flex:1 + text-align:center
2. 说明textarea 与 MCP传输代码 框紧挨着无分隔 —
   .hub-code-section 加 margin-top: var(--space-xl)
2026-07-16 16:39:12 +08:00
QihuAI Design e95fec0c1b ui(admin): 修复后台侧边栏 logo 区域未垂直居中
.admin-sidebar-header 原 align-items: baseline + height: var(--header-height) + padding: 0
导致 logo 紧贴顶部边线。

修复:
- align-items: baseline → center
- padding: 0 var(--space-lg) → var(--space-md) var(--space-lg)
- height → min-height
- justify-content: center (水平居中)
- logo/subtitle 加 line-height: 1 + display: flex align-items: center
2026-07-16 16:30:57 +08:00
QihuAI Design dc577eb0f0 feat(chat): 增强AI回复渲染 — 代码高亮/KaTeX数学/删除线/URL自动链接
新增渲染能力:
1. highlight.js 代码语法高亮 + 复制按钮 (hover显示)
2. KaTeX 数学公式渲染 ($$...$$块级 + $...$行内)
3. 删除线 ~~text~~ → <del>
4. 裸URL自动转可点击链接

依赖: npm install highlight.js katex mermaid
2026-07-16 16:25:41 +08:00
QihuAI Design 8486a2a0e5 ui(chat): MarkdownContent 支持表格渲染
AI 聊天回复中常出现 Markdown 表格语法 (| h1 | h2 | + |---|---| ),
但 MarkdownContent 仅支持 headers/lists/code,未渲染表格,
显示为原始 | 分隔文本。

修复:
1. ChatPage.tsx MarkdownContent 新增表格解析步骤(步骤 2.7)
   - 匹配 | header |
|---|---| 的多行表格块
   - 解析对齐方式 (:---, :---:, ---:)
   - 生成 <table><thead><tbody> HTML
2. 步骤 10 恢复占位符,步骤 11 解包 <table> 不被 <p> 包裹
3. ChatPage.css 新增 .md-table 样式
   - 蓝色渐变表头 + 斑马纹 + hover 高亮
   - 用户消息气泡内深色适配
2026-07-16 16:15:29 +08:00
QihuAI Design 845355e4e7 fix(timezone): 容器时区统一为 Asia/Shanghai,DB 历史 UTC 时间批量 +8h 修正
根因:容器 TZ 为空(默认 UTC),Python datetime.now() 返回 UTC 写入
MySQL datetime 列时无时区转换,导致存储值被当成北京时间显示(偏 8h)。

修复:
1. run.sh 加 export TZ=Asia/Shanghai,新写入的时间即为北京时
2. 批量 UPDATE 所有 datetime 列 + INTERVAL 8 HOUR(105 表 216 列)
3. market_apps 等 59 表的 updated_at 因 ON UPDATE 触发器被重置,
   改为 = created_at(用户未实际编辑过)

后续:新写入的数据天然为北京时;旧数据已对齐。
2026-07-16 16:07:59 +08:00
QihuAI Design 4d27dd3429 ui(import-hub): 优化 skills.sh 卡片布局
- skills.sh 卡片改为列布局:
  顶部:图标+名称+URL(左侧)+ 访问按钮(右上)
  中部:URL 输入框(全宽)
  底部:检测名称预览 + 导入按钮(右下)
- 加大"说明"与平台卡片的间距(margin-top: space-xl)
- 导入按钮与名称预览在右侧同一行,间距更协调
2026-07-16 16:00:35 +08:00
QihuAI Design 0e6f1931e1 fix(skills-admin): market 项加 publish_records 字段,列表"已发布"标识正确显示
修复顶部卡片(已发布=50)与列表行(市场=未发布)不一致问题:
列表行 isPublished 仅看 skill.publish_records[],
market_apps 项无该字段故一直显示"未发布"。
改为 market_app_to_skill_dict 中按 status 注入 publish_records。
2026-07-16 15:46:43 +08:00
QihuAI Design 8942339744 fix(skills-admin): get_skills_stats 合并 market_apps(type=skill) 统计
修复顶部卡片仍显示 0 的问题:list_skills 已支持合并查询,
但 stats/overview 端点仍只读 evolution_skills。
新增对 market_apps 的统计: published→main, 其它→baby。
验证: total_skills=50, by_state.main=50, total_published=50
2026-07-16 15:39:21 +08:00
QihuAI Design ce81297159 fix: 修复技能市场分页、后台Skills管理、微信Bot图片生成超时
1. MarketPage.tsx: getApps 加 page_size=100,修复只显示20条
2. skills_admin.py: list_skills 同时查询 market_apps(type=skill)
   解决前台导入50个技能后台看不到(前后台用不同表)
3. im_llm.py: _run_async 加 timeout 参数,_call_real_llm 媒体生成
   5分钟超时保护,解决微信Bot图片生成无限期挂住的问题
2026-07-16 15:10:50 +08:00
QihuAI Design b3866f770c fix: 全站时间统一为北京时间(Asia/Shanghai)
新增 src/utils/datetime.ts 共享 formatBjTime 工具,
13 个页面(11后台+2前台) 的 toLocaleString/formatDate
改为显式北京时间。Dashboard.tsx 保留图表标签的
formatDate(split) 不受影响。
2026-07-16 12:39:35 +08:00
QihuAI Design 82afe14a23 fix(wechat/token/market): 修复微信对话时间、Token数据重置、技能市场复活、媒体内联
1. 微信Bot对话记录时间按北京时间渲染(ImBotHub/WechatHistory 的 formatTime 显式 +08:00 + Asia/Shanghai);
2. 新增 /app/api/chat/media/relay 媒体中转端点(白名单 agnES 域名, 经香港代理代取),
   改写 _wechat_send_media 用平台中转地址,微信/iLink 可内联播放图片/视频;
3. 删除 014_clean_test_data.sql:该迁移每次重启重跑并 DELETE token_usage_logs/
   messages/conversations/knowledge 等,导致 Token 驾驶舱数据每次重启被清空;
4. 002_init_data.sql 移除 market_apps 自动播种(原 WHERE COUNT=0 在用户删空后重启复活),
   技能市场交由用户后台维护,重启不再复活。
2026-07-16 09:19:23 +08:00
QihuAI Design da89a27bca fix(wechat-bot): ①对话时间统一北京时间 ②媒体内联发送(微信直看图片/视频) ③媒体生成计入Token驾驶舱 ④清空技能市场 2026-07-15 23:37:30 +08:00
QihuAI Design 7739f17594 fix(wechat-bot): 修复 AGNES 媒体生成失败——优化步骤相对导入路径错误(从未生效)+ 国内模型直连 + 内容策略重试 2026-07-15 22:04:22 +08:00
QihuAI Design fd2011b93e fix: AGNES 三个模型改走香港代理(实测延迟更低、更稳定) 2026-07-15 20:17:44 +08:00
deploy ccc5f799e1 fix(media): AGNES 图像/视频生成加韧性层——指数退避重试+友好错误翻译+Bot解析加固
根因(实测复现): AGNES 外部服务不稳定, 非平台代码问题:
- 视频创建常返 500 publish_video_queue_failed / context deadline exceeded
- 连接偶发重置 Server disconnected
- 视频硬限频 每分钟 1 次 (429 rate limit)

改动:
1. chat.py 新增 _agnes_post_json(): 对 5xx/连接中断做 3 次指数退避重试(2/4s);
   4xx(参数错误)不重试直接返回
2. 新增 _friendly_media_err(): 把 AGNES 原始 HTTP/JSON 错误
   转译为对用户友好的中文(限频/连接失败/队列繁忙), 不再透传原始报错
3. _generate_media 图像/视频分支改用上述重试+友好错误
4. im_llm.py Bot 媒体结果解析加固: strip 后判 <img>/<video>,
   成功返回干净链接; 失败透传友好中文, 绝不把原始提示词/标签甩给用户

验证(容器内实测):
- WEB_IMG OK / WEB_VID OK / BOT_IMG OK (均返回真实 png/mp4)
- 429/断开/队列三类错误均正确转译为友好中文
2026-07-15 19:58:06 +08:00
deploy b88f883d44 feat(market): admin用户前台导入的Agent/Skill/MCP自动标记为官方(official)
- POST /market/apps: 当 current_user.role == "admin" 时,source 强制设为 official
- 前端发送 source:/user/ 也会被后端覆盖为 official
- 前台卡片已有 official-badge 徽章展示,无需额外前端改动
2026-07-15 18:58:15 +08:00
deploy 6416fb4651 fix(admin): 修复注册设置PUT接口——由独立query参数改为接收JSON body
根因:PUT /admin/users/register-settings 参数定义为 phone_required: bool = None 等独立query参数,
但前端发送的是 JSON body {"phone_required":true,"email_required":true}。
FastAPI无法把JSON body映射到独立参数 → 全部收到None → 循环跳过 → DB无更新(提示成功但无效)。
修复:参数改为 body: dict 统一接收JSON,用 body.get() 取值更新 RegisterSetting 表。
2026-07-15 18:50:36 +08:00
deploy 148e4a21c2 fix: 全面修复6项问题——Auto模型路由/媒体提示词优化/微信Bot/Token驾驶舱/技能市场清空
1. resolve_auto_model: image/video/audio无显式默认时自动发现启用模型(原返回None→文本模型回退→AI说无法生成)
2. 新增_optimize_media_prompt: 调用文本LLM精炼用户原始输入为AGNES高质量提示词(去除对话噪音+英文优化)
3. detect_media_intent: 扩展关键词覆盖多轮对话模式(做图片/生成个图/画一下/制作视频等)
4. chat.py+im_llm.py: 所有httpx调用补trust_env=False+_proxy_for_url, 规避容器坏代理
5. token_usage.py: 模型名回退链(model表→日志自带的model_name→auto标签), 不再显示unknown
6. skills_admin.py: 新增POST /batch-delete批量删除API(ids空则删全部)
7. 技能市场数据已清空, 用户可重新导入技能
2026-07-15 17:05:00 +08:00
deploy 4d57987aa5 fix(agent): 外部Dify Agent 手动跟随http->https 301重定向并保持POST(测试连接+运行期);qihuyun.com直连 2026-07-15 14:30:24 +08:00
qihu-bot d274733fb3 feat(chat): 外部Agent(Dify/n8n/Coze)运行期真实远程调用
- _resolve_market_agents 补齐 platform/agent_endpoint/agent_api_key 字段
- 新增 _call_external_agent 及 dify/n8n/coze 分支; Dify 走SSE流式,兼容
  chat/agent(message)与chatflow/workflow(text_chunk/workflow_finished)事件,
  失败时surface真实错误; n8n走webhook POST; coze走v3 chat+轮询
- send_message 选中带endpoint的外部Agent时直接远程调用,跳过本地LLM扮演
- Conversation新增ext_agent_conversation_id维持Dify/Coze多轮(迁移016)
- 出网统一trust_env=False+_proxy_for_url直连,规避坏代理502
2026-07-15 13:55:04 +08:00
QihuAI Design d374d464e8 feat: 智能Agent接入前连通性/鉴权校验
- 前端技能市场「接入单个Agent」新增「测试连接」按钮,接入前必须校验通过

- 后端 POST /market/apps/verify-agent:dify 走 chat-messages 鉴权探测(trust_env=False 直连),n8n/coze 走 HEAD 可达性探测

- upload_app 对 agent 类型做服务端守卫,密钥无效/不可达直接 400 拒绝

- MarketApp 新增 platform 列(dify/n8n/coze),009 迁移幂等加列
2026-07-15 13:01:19 +08:00
QihuAI Design b0bfba5c16 fix: 技能/应用市场官方种子改为仅空表时写入,修复删掉的技能重启后被迁移脚本复活
- run.sh 每次重启无条件重跑 migrations/002_init_data.sql

- 原 INSERT IGNORE 在用户删行(PK消失)后会重新插入,导致已删技能复活

- 改为 INSERT...SELECT...WHERE (SELECT COUNT(*) FROM market_apps)=0,仅全新库播种,用户删除任一后表非空即不再复活
2026-07-15 12:38:36 +08:00
QihuAI Design 996d5413da fix: 修复AI聊天/IM多项问题(媒体直连+技能自动发现+agent/mcp上下文)
- Issue4/7: 图像/视频生成对AGNES国内域名强制直连(trust_env=False),修复Server disconnected(代理172.18.0.1:7890返回502)

- Issue8: 修复_parse_skill_content缺失def导致的NameError;新增_discover_installed_skills默认技能自动发现

- Issue2/3: system prompt注入agent资料与mcp描述/endpoint

- Issue7: IM Bot制作图片走Auto路由媒体模型
2026-07-15 12:23:56 +08:00
QihuAI Design bc2899ed09 前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key) 2026-07-15 10:33:43 +08:00
QihuAI Design 463824480b fix: 知识库文档上传(尾斜杠+FormData+真文件接收); AI回复强制Markdown; 平台全功能知识库注入系统提示 2026-07-14 23:04:30 +08:00
QihuAI Design 62631a3b95 feat: 技能市场Hub导入MCP/Skills添加名称说明输入;MCP传输代码验证后独立导入按钮 2026-07-14 22:33:43 +08:00
QihuAI Design c0ec9e0e88 fix: ChatPage loadMessages 中 mapped.forEach 闭包错误导致页面白屏;移至 async 内部 2026-07-14 22:21:16 +08:00
QihuAI Design 5d1370156e fix: 停止迁移复活默认模型供应商/模型;AI聊天媒体生成改为异步(修复视频请求失败)
- migrations/002_init_data.sql: 移除 model_providers/models 的 INSERT IGNORE 种子,重启容器不再复活 deepseek/openai/通义千问等默认供应商与模型,改由用户在后台大模型库自行维护
- chat.py: 图像/视频生成分支改为立即返回占位(generating=true),后台 asyncio 任务轮询 AGNES 并回写消息内容,避免最长 5 分钟同步轮询阻塞 HTTP 请求导致前端「发送失败:请求失败」
- ChatPage.tsx: 发送后若 assistant_message.generating,每 3s 轮询 getMessages 直至内容更新;页面刷新时若仍有「⏳」占位继续轮询;卸载清理定时器
2026-07-14 21:48:39 +08:00
QihuAI Design b7f76c3e92 refactor(admin): 将「数据分析」从数据驾驶舱移至待处理板块 2026-07-14 21:21:35 +08:00
QihuAI Design a1669374e6 feat(kb): 接入 Dify 知识库,移除 FastGPT;AI 聊天产出+用户文档自动入库量化
- 新增 app/dify_kb.py Dify v1 客户端(连通测试/检索/文本入库)
- kb_router 仅保留 Dify 外部检索分支
- knowledge_admin 去掉 FastGPT/RAGFlow,新增 dify 批量入库端点
- chat.py 注入 Dify RAG 上下文 + 助手回答 fire-and-forget 入库
- 前端 Knowledge.tsx 改为 Dify 单服务 UI(可通讯/可用验证+入库按钮)
- DB: external_kb_services 删除 fastgpt,qihuai 设为 https+connected
2026-07-14 20:56:53 +08:00
QihuAI Design d3a890eb18 Round6 fix: 自动化任务提交按钮文案/去掉创建分支; 补建 sms_verification_codes 表 2026-07-14 19:38:50 +08:00
QihuAI Design d179cc2f02 Round6: 前台图片缩略图+下载/视频修复; 后台连接测试单次+自动消失; 导航重组; 存储中文化; 自动化去掉创建按钮; 短信验证码(注册/重置/充值通知) 2026-07-14 18:49:27 +08:00
QihuAI Design ba09017884 fix: AGNES图像/视频生成强制经智能路由代理(172.18.0.1:7890),修复容器内直连被重置导致图像生成异常 2026-07-14 17:11:12 +08:00
QihuAI Design 202a181ebe 同步页面正文标题改名:付费中心→会员订阅、微信机器人→微信Bot、会员订阅→会员管理、应用市场审核→AI技能审核、技能管理→Skills技能管理、MCP服务→MCP Client管理、支付渠道配置→支付渠道、广告位管理→广告管理 2026-07-14 12:11:50 +08:00
QihuAI Design fcc84d98df 增强后台数据驾驶舱:6张指标卡(近7天活跃/订阅成功/文档数等)+30天Token趋势+操作/审计日志分页与CSV下载 2026-07-14 12:00:19 +08:00
QihuAI Design 6525b4d994 feat: 补齐缺失页 Phase1 - 多平台IM Bot(企业微信/飞书/QQ/悟空IM)+应用服务页+路由导航 2026-07-14 11:38:08 +08:00
QihuAI Design fa01b5df5b refactor: 按新规格重组前后台菜单导航(前台分组+改名、后台5板块+待处理) 2026-07-14 11:02:21 +08:00
QihuAI Design 6e4fcad2b9 style: 首页轮播箭头默认隐藏(hover显现)、指示器圆点去边框极淡透明 2026-07-14 10:40:52 +08:00
QihuAI Design 730065895a style: 首页广告位优化——移除冗余描述、标题居中、轮播控件透明化 2026-07-14 09:23:57 +08:00
QihuAI Design ab54d7344c refactor: 移除通用频道管理(LangBot)模块,IM Bot 改为直接对接;迁移 _call_real_llm 至 im_llm 2026-07-14 08:21:56 +08:00
QihuAI Design 945eeb8ba0 feat: 模型库能力默认模型UI + 前台Auto默认模型 + AGNES直连修复 + 意图识别增强 2026-07-14 05:47:57 +08:00
qyyfw 2847289921 fix(front): restore grouped sidebar with IM Bot section (channels + wechat-bot); normalize brand color #5B5FED 2026-07-13 23:35:11 +08:00
QihuAI Design 9c684076f3 feat(front): unify all front user pages to DESIGN.md brand #5B5FED; fix decimal-rgba off-brand purples missed by hex normalization (MarketPage focus shadow, LoginPage radial glow) 2026-07-13 23:07:26 +08:00
QihuAI Design a48873d7ce fix: 后台「访问前台」改为进入用户工作界面 /chat(而非营销落地页) 2026-07-13 21:43:56 +08:00
QihuAI Design 5a6a7a5fb7 fix: 后台「访问前台」打开独立公开路由 /home,绕过管理员登录重定向 2026-07-13 21:15:48 +08:00
QihuAI Design 5617d4304b fix(admin): 后台侧栏按DESIGN.md翻浅色主题+品牌色#5B5FED;修正CSS类名错配(.sidebar-nav/.nav-item)使全部功能板块可见;默认展开所有分组 2026-07-13 20:50:47 +08:00
QihuAI Design 9b87012248 feat(frontend): 首页广告位网格溢出根治(minmax(0,1fr) minmax(0,1.2fr)+min-width:0) + 精选推荐对接平台广告接口 GET /app/api/market/ads 2026-07-13 20:01:13 +08:00
QihuAI Design e38e18fbe7 feat(admin): 短信配置独立页+短信宝支持;后台顶部面包屑导航优化 2026-07-13 19:24:13 +08:00
QihuAI Design c275cc3b5e fix(frontend): 广告位改为右2/3内收卡片——去grid gap使左边界紧贴标题右缘,右边界距页面右缘内收32px,加1px边框形成独立盒子 2026-07-13 18:19:22 +08:00
QihuAI Design 21b8422c87 fix(frontend): 精选推荐轮播溢出修复——.lp-carousel 加 overflow:hidden+border-radius 裁剪离屏幻灯片;.lp-slide 加 16px 内边距形成边界与阴影余量 2026-07-13 17:24:18 +08:00
QihuAI Design a7b9b2492e feat(frontend): 首页改版——Hero合并广告位(2/3)、去PaaS/去对话卡、价值区/技能榜/应用中心文案、源index.html去HashRouter脚本、prerender去重注入 2026-07-13 16:21:42 +08:00
QihuAI Design c7fea64ae7 chore(frontend): 清理 LandingPage 中关于 HashRouter 的历史注释(项目已迁 BrowserRouter) 2026-07-13 15:48:06 +08:00
QihuAI Design d404aa4e29 feat(frontend): HashRouter->BrowserRouter 干净URL可独立索引 + 静态预渲染(SSR注入#root/robots/sitemap) + logo回顶 2026-07-13 15:01:53 +08:00
QihuAI Design a1bea39d9f design: 增强设计系统(渐变/玻璃拟态/SEO-GEO/页面布局) + 元宝提示词解读 2026-07-13 11:17:47 +08:00
qyyfw e7dc9808c0 docs: 沉淀七虎AI 设计系统(DESIGN.md/MASTER.md/.impeccable.md) 作为UI单一事实源 2026-07-13 09:10:30 +08:00
qyyfw 5bc61827d7 docs: RELEASES v2026.07.12-ui-recover 2026-07-12 18:40:28 +08:00
qyyfw f545e81b20 chore: freeze v2026.07.13-recover (deterministic recovery, git==container) 2026-07-12 16:48:12 +08:00
137 changed files with 29558 additions and 14234 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# qihuaiflow minimal CI (Gitea Actions)
#
# NOTE: This workflow is DORMANT until a Gitea act_runner is registered with
# this repo. The active gate today is the host-side pre-push hook
# (scripts/git-hooks/pre-push) + scripts/ci_gate.py, because our deployment
# model keeps the app running in a long-lived `qihu-aiflow` container on the
# docker host rather than building it fresh in CI.
#
# To activate later:
# 1. Install act_runner on the docker host (sharing the docker socket).
# 2. Register it against this repo with label `ubuntu-latest`.
# 3. The runner can then `docker exec qihu-aiflow ...` to reach the app.
#
# Until then, the pre-push hook already blocks broken pushes.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Backend pytest
run: docker exec qihu-aiflow bash -c 'cd /app/backend/python && .venv/bin/python -m pytest -c pytest.ini -q'
- name: Frontend vitest
run: docker exec qihu-aiflow bash -c 'cd /app/frontend && npx vitest run --reporter=dot'
+29
View File
@@ -0,0 +1,29 @@
# .impeccable.md — 七虎AI 设计上下文
本文件为 `impeccable` 技能提供项目级设计上下文。完整规范见 **`DESIGN.md`**(单一事实源)与 `design-system/MASTER.md`。
## Audience & Use Case
七虎 AI 是面向企业与开发者的 **AI PaaS 平台**:智能体编排、技能/市场、模型库、工作流、IM Bot(个人微信)。使用者是开发者与运营人员,需要高密度、可信、精密的控制台界面。
## Brand & Tone
- **风格**:温暖科技感 + 极简主义(Warm Tech Minimalism)。
- **唯一品牌色**:靛蓝 `#5B5FED`(hover `#4A4ECC`,浅底 `#EEEDFF`)。语义色仅表状态(成功 `#219B5E` / 警告 `#E8922C` / 错误 `#D9383A` / 信息 `#3B82F6`)。
- **基调**:专业、可信、有温度;留白与柔和阴影营造呼吸感;动效克制(150–300ms)。
## Tech & Constraints
- 前端:React 18 + TypeScript + Vite 5,HashRouter,浅色主题。
- 设计 token 全在 `frontend/src/styles/global.css` 的 `:root`(颜色/间距/圆角/字体/阴影)。**新增视觉必须先加 token,禁止散落 hex。**
- 字体:Inter(文本)+ JetBrains Mono(代码/ID/参数)。
- 图标:**Lucide**,禁止 emoji 当图标。
- 布局:左侧 sidebar(240/折叠64)+ 顶 header(56)+ 主内容区;详情用右侧 drawer。
## Build & Verify(交付铁律)
1. 改完 `frontend/src` 后必须在容器内 `npm run build`。
2. 校验新 `dist` 确实含改动(grep 标记或构建日志),再请用户**硬刷新验收**。
3. 每轮收工:`git commit + push` 到 `git.qihuyun.com`,并 `docker commit` 同名镜像(`qihuaiflow:with-deps`)+ 备份上一版,确保容器重建不丢。
## Quality Bar(impeccable 验收)
- 所有可点击元素有 `cursor:pointer` + hover + 可见 focus 环。
- 对比度 ≥4.5:1;尊重 `prefers-reduced-motion`。
- 响应式:375 / 768 / 1024 / 1440;无横向滚动。
- 不引入第二品牌色、不用 emoji 图标、不用布局位移的 scale hover。
+275
View File
@@ -0,0 +1,275 @@
# 七虎AI · DESIGN.md
> **用途**:这是七虎 AI PaaS 平台的**唯一设计事实源(single source of truth)**。任何 AI 编码代理在新增/修改前端页面或组件时,必须读取本文件并严格遵守其中的设计 token、组件规范与 Do/Don't。
> **风格方向**:温暖科技感 + 极简主义(Warm Tech Minimalism)。浅色为主(护眼雾白底),靛蓝 `#5B5FED` 为唯一品牌色;允许克制的「淡蓝紫→淡青蓝」渐变与轻量玻璃拟态(Glassmorphism);前台必须符合 SEO/GEO 语义规范。
> **生成方式**:基于平台现有 `frontend/src/styles/global.css` 的真实 token 固化升华,并经由 `ui-ux-pro-max` 技能的数据驱动生成器校验风格一致性。
> 关联文件:`design-system/MASTER.md`(Master+Overrides 模式,含组件 CSS 样板)、`.impeccable.md`(impeccable 技能入口)。
---
## 1. Visual Theme & Atmosphere
七虎 AI 是一个面向企业与开发者的 AI PaaS 平台(智能体编排、技能市场、模型库、工作流、IM Bot)。界面需要传达**专业、可信、精密**,同时带有**温度**(不冰冷)。
- **Mood**:沉着、秩序、精密、可信任;用留白与柔和表面营造呼吸感。
- **Density**:中高密度(开发者控制台),信息密集但不拥挤;关键操作常驻(header + sidebar)。
- **Design philosophy**:
- 单一品牌色(靛蓝)贯穿全站,语义色只用于状态(成功/警告/错误/信息)。
- 表面层级用极浅灰阶 + 柔和阴影区分,而非重边框。
- 动效克制:150–300ms 缓动,仅做"提示"不抢戏。
- 全站浅色主题;深色模式暂不在范围内。
## 2. Color Palette & Roles
所有色值以 CSS 变量定义在 `global.css` 的 `:root`。**新增颜色必须先定义为 token,禁止散落硬编码 hex。**
### 中性 / 表面
| 语义名 | Hex | 角色 |
|---|---|---|
| `--color-bg-primary` | `#F8F9FB` | 页面主背景(最浅) |
| `--color-bg-secondary` | `#FFFFFF` | 卡片 / 浮层表面 |
| `--color-bg-tertiary` | `#F1F3F7` | 输入框底 / 次级表面 |
| `--color-bg-hover` | `#EBEEF3` | hover 表面 |
| `--color-bg-active` | `#E3E7F0` | 选中 / 激活表面 |
### 文本
| 语义名 | Hex | 角色 |
|---|---|---|
| `--color-text-primary` | `#101214` | 主文本 / 标题 |
| `--color-text-secondary` | `#545A65` | 次要文本 |
| `--color-text-tertiary` | `#8B919E` | 提示 / 占位 |
| `--color-text-disabled` | `#BFC4CE` | 禁用 |
| `--color-text-inverse` | `#FFFFFF` | 深色表面上的文本 |
### 品牌 / 语义
| 语义名 | Hex | 角色 |
|---|---|---|
| `--color-accent` | `#5B5FED` | **唯一品牌色**(主按钮、激活态、链接、焦点环) |
| `--color-accent-hover` | `#4A4ECC` | 品牌色 hover |
| `--color-accent-light` | `#EEEDFF` | 品牌色浅底(tag / 选中背景) |
| `--color-accent-subtle` | `#F6F5FF` | 品牌色极浅底 |
| `--color-success` | `#219B5E` | 成功 / 在线 |
| `--color-warning` | `#E8922C` | 警告 |
| `--color-error` | `#D9383A` | 错误 / 危险 |
| `--color-info` | `#3B82F6` | 信息 |
| (各语义的 `-light` 变体) | `#E6F7EE` / `#FEF4E8` / `#FDEBEB` / `#EBF2FE` | 浅底色块 |
### 边框 / 侧栏
| 语义名 | Hex | 角色 |
|---|---|---|
| `--color-border` | `#E3E6EB` | 默认边框 |
| `--color-border-light` | `#EEF0F4` | 极浅分隔线 |
| `--color-border-hover` | `#CDD2DA` | 边框 hover |
| `--color-sidebar-bg` | `#F5F6F9` | 侧栏背景 |
| `--color-sidebar-hover` | `#EAECF2` | 侧栏项 hover |
| `--color-sidebar-active` | `#E1E3F0` | 侧栏项激活 |
### 渐变 / Gradients(新增,源自"护眼科技感 + 渐变"需求)
平台允许**克制**的渐变,用于背景 / 装饰 / 主行动点,绝非赛博朋克式高饱和。所有渐变必须先定义为 token:
| 语义名 | 值 | 角色 |
|---|---|---|
| `--gradient-brand` | `linear-gradient(135deg, #EEEDFF 0%, #E3F0FF 100%)` | Hero / 区块浅底(淡蓝紫 → 淡青蓝) |
| `--gradient-brand-strong` | `linear-gradient(135deg, #5B5FED 0%, #4F8DF9 100%)` | 主 CTA / Banner 强调(品牌色渐变,不刺眼) |
| `--gradient-aurora` | `radial-gradient(60% 60% at 30% 20%, rgba(91,95,237,.10), transparent 70%)` | 仅 Hero 装饰光斑,绝不直接衬文字 |
**渐变铁律**:
- 渐变只作背景 / 装饰 / CTA;**承载正文文字的表面必须纯色**(`#FFFFFF` / `#F8F9FB`),确保 ≥4.5:1。
- 拒绝全屏流动 / 循环渐变动画(aurora 8–12s loop)作用于文字区;装饰光斑在 `prefers-reduced-motion` 下静止。
- 不引入品牌色之外的第三色相做渐变(仅「靛蓝 → 青蓝」的同系过渡)。
**对比度约束**:文本在浅色表面上至少 4.5:1(WCAG AA)。`--color-text-tertiary` 仅用于非关键提示,不用于正文。
## 3. Typography Rules
- **字体**:`Inter`(正文/标题)+ `JetBrains Mono`(代码、模型 ID、token、技术参数)。中文回退系统字体。
- CSS import:`@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');`
- **字阶(scale)**:
| Token | 尺寸 | 用途 |
|---|---|---|
| `--text-xs` | 11px | 角标 / 极次要 |
| `--text-sm` | 13px | 辅助文本 / 列表项 |
| `--text-base` | 14px | 正文默认 |
| `--text-md` | 15px | 稍强调正文 |
| `--text-lg` | 18px | 小标题 |
| `--text-xl` | 22px | 区块标题 |
| `--text-2xl` | 28px | 页面标题 |
| `--text-3xl` | 36px | Hero / 大标题 |
- **字重**:正文 400,标题/强调 500–600,数字/统计可 700–800。避免过度加粗(≤600 为主)。
- **行高**:正文 `line-height: 1.6`;标题 1.3–1.4。
- **中文**:优先 Inter 的拉丁字形 + 系统中文回退;避免中文用 mono。
## 4. Component Stylings
(完整 CSS 样板见 `design-system/MASTER.md`;此处为快速参考。所有组件需 `cursor: pointer`、hover 过渡 150–300ms、可见 focus 环。)
### Buttons
- **Primary**:`background: var(--color-accent); color:#fff; border-radius: var(--radius-md); padding: 10px 18px; font-weight:500;` hover → `--color-accent-hover` + `translateY(-1px)`。
- **Secondary**:透明底 + `1px solid var(--color-accent)` + 品牌色文字;hover 浅底 `--color-accent-subtle`。
- **Ghost**:无边框无底,hover 仅 `--color-bg-hover`;用于工具栏图标按钮。
- **Danger**:`var(--color-error)` 文本/边框,确认类操作用。
### Cards / Panels
- `background: var(--color-bg-secondary); border:1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-2xl);`
- hover(可点击卡片):`box-shadow: var(--shadow-lg); translateY(-2px)`。
- 数据卡(KPI):上方 label(tertiary)+ 大数字(text-2xl, weight 700)+ 趋势小标。
### Inputs / Textarea / Select
- `padding:10px 14px; border:1px solid var(--color-border); border-radius: var(--radius-md); font-size: var(--text-base); background: var(--color-bg-secondary);`
- focus:`border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-light); outline:none;`
- placeholder:`var(--color-text-tertiary)`。
### Navigation / Sidebar
- 宽 `--sidebar-width: 240px`(折叠 64px);项高 ~40px,圆角 `--radius-md`,hover/active 用 `--color-sidebar-hover` / `--color-sidebar-active`;激活项左或文字用品牌色。
- 顶部 header 高 `--header-height: 56px`,承载主操作与全局搜索。
### Tags / Badges / Chips
- 用 `--color-accent-light` 浅底 + 品牌色文字表示"能力/标签";语义状态用对应 `-light` 底(success/warning/error/info)。
- 图标统一用 **Lucide**(见 §7),禁止 emoji 当图标。
### Modals / Drawers
- overlay:`rgba(0,0,0,0.5)` + `backdrop-filter: blur(4px)`。
- 弹窗:`background:#fff; border-radius: var(--radius-xl); padding: var(--space-3xl); box-shadow: var(--shadow-xl);` 右侧抽屉(detail drawer)用于 Agent/技能详情。
### Glassmorphism(轻量玻璃拟态变体)
用于浮层 / 导航 / Hero 叠加 / 横幅,营造通透科技感(技能库 Glassmorphism 条目:backdrop blur 10–20px、半透明白 15–30%、细边框)。
```css
.glass {
background: rgba(255,255,255,0.65);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(255,255,255,0.7);
box-shadow: var(--shadow-md);
}
```
- 浮层导航 / 横幅 / Hero 卡片用 `.glass`;**正文内容卡保持纯白 `#FFFFFF`**(对比与清晰度优先)。
- 文字在玻璃面上必须 ≥4.5:1(玻璃面下若为渐变底,需加深文字或加底纹)。
### Neumorphism(默认禁用)
用户原话提到"轻拟态(Neumorphism)或玻璃拟态的轻量化变体"。技能库明确标注 Neumorphism **对比度低(⚠ Low contrast)**,不利护眼。
**决策:默认禁用完整新拟态**;若需"轻量"立体感,仅允许在**无文字的装饰表面**用极淡 inset 阴影,且不得影响可读性。优先采用 Glassmorphism 轻量变体。
## 5. Layout Principles
- **间距阶梯**(必须用语义 token,禁止随意像素):`xs4 / sm8 / md12 / lg16 / xl20 / 2xl24 / 3xl32 / 4xl40 / 5xl48`。
- **栅格**:内容最大宽度 1440px 居中;主工作区用 12 列弹性栅格;卡片间距 `--space-lg`。
- **留白哲学**:区块间 `--space-3xl`~`--space-5xl`;卡片内 `--space-2xl`;避免贴边,页面左右留 `--space-2xl`+。
- **圆角**:小组件 `sm6/md8`,卡片/面板 `lg12`,弹窗 `xl16`,全圆 `--radius-full`(头像/徽标/药丸按钮)。
- **App Shell**:左侧 sidebar(工作区导航)+ 顶 header(全局操作)+ 主内容区;IM Bot / 技能详情用右侧 drawer 不离开上下文。
## 6. Depth & Elevation
阴影层级(柔和、低透明,营造"浮起"而非"描边"):
| Token | 值 | 用途 |
|---|---|---|
| `--shadow-xs` | `0 1px 2px rgba(0,0,0,.04)` | 内部分隔 |
| `--shadow-sm` | `0 1px 3px rgba(0,0,0,.06)` | 静态卡 |
| `--shadow-md` | `0 4px 12px rgba(0,0,0,.06)` | 卡片 / 按钮 |
| `--shadow-lg` | `0 12px 32px rgba(0,0,0,.08)` | hover 卡 / 下拉 |
| `--shadow-xl` | `0 20px 48px rgba(0,0,0,.10)` | 弹窗 / 浮层 |
规则:表面层级靠"背景明度 + 阴影"双信号区分;禁用重投影与硬黑边。
## 7. Do's and Don'ts
**Do**
- 所有可点击元素加 `cursor: pointer` 与可见 hover/focus 态。
- 用 Lucide SVG 图标;技术参数用 JetBrains Mono。
- 状态用语义色;品牌表达只用 `--color-accent`。
- 动效克制(150–300ms ease),尊重 `prefers-reduced-motion`。
- 加载态用 skeleton shimmer,避免生硬 spinner。
**Don't**
- ❌ 用 emoji 当图标。
- ❌ 引入除靛蓝外的第二个"品牌色"(除非语义状态)。
- ❌ 低对比文本(<4.5:1)。
- ❌ hover 用 `scale` 导致布局位移。
- ❌ 瞬时状态切换(无过渡)。
- ❌ 焦点态不可见(键盘可达性)。
- ❌ 在 `global.css` 之外散落硬编码 hex。
## 8. Responsive Behavior
断点:`375px`(手机)/ `768px`(折叠 sidebar 为 64px 或抽屉)/ `1024px`(平板)/ `1440px`(桌面最大宽)。
- 移动端:sidebar 转**底部 tab 栏**(前台门户)或**左侧抽屉**(后台,因菜单项多);主内容单列;触摸目标 ≥44px。
- 平板:sidebar 折叠 64px,悬停展开。
- 栅格在窄屏降列数(12→6→1)。
- 禁止横向滚动;表格在窄屏转卡片或横向滚动容器(包 `overflow-x-auto`)。
- 必须 `<meta name="viewport" content="width=device-width, initial-scale=1">`;固定导航需 `padding-top` 补偿高度,避免遮挡首屏内容。
## 10. SEO & GEO 规范(前台必须)
平台需"不推广也能被搜索引擎与各大模型联网搜索(GEO)发现"。
### 结构(语义化 HTML5)
- 每页一个 `<h1>`;区块用 `<section>` / `<article>`;技能卡用 `<article>` 包裹(标题 `<h2>`/`<h3>`)。
- 必须 `<meta name="viewport">`、`<title>`、`<meta name="description">`、Open Graph 标签。
- 前台用 `<header><nav><main><footer>` 语义骨架;后台为应用壳,SEO 权重低。
### 富媒体摘要(Rich Snippets / JSON-LD)
- 技能页 / 技能卡:`SoftwareApplication` schema,含 `aggregateRating`、`offers`(`price:0` / 订阅)、`downloadCount`。
- 技能市场:`BreadcrumbList`;痛点 / FAQ 区:`FAQPage`。
- 预留展示槽:评分星、价格、下载量在卡片右上角(Lucide 图标 + 数字,非 emoji)。
### 关键词突出(GEO 友好)
- 核心词「无限Token」「AI变现」「办公提效」「低成本养龙虾(= 低成本微调 / 托管模型)」用 `.kw` 样式(品牌色 + 600 字重)在首页价值区高亮。
- `.kw { color: var(--color-accent); font-weight:600; }`
### 技术
- `sitemap.xml` + `robots.txt`;技能详情独立可索引 URL;所有图片 `alt`。
## 11. 页面级布局总览(前台 + 后台)
> 详细页级 override 将在实际开发时落入 `design-system/pages/*.md`(Master + Overrides)。此处为总览与验收基准。
### 前台首页(Home)
1. 浮层玻璃顶栏(logo + 主导航 + 登录 / 注册)
2. Hero:轮播 Banner(技能广告位)+ 平台定位一句话 + 主 CTA
3. 平台定位区:图标 + 短文网格(痛点:API 贵 / Prompt 难调试 / 数据孤岛 / 无变现渠道)
4. 平台价值区:可视化数据点(低价订阅 / 无限 Token / 垂直办公模板 / 交易市场 / 低成本养龙虾)
5. 热门技能榜 Top5(排行榜 / 徽章)
6. Footer(SEO 链接 + 富媒体摘要)
### 技能市场(Skill Market)
- 顶部广告位 → 左侧筛选(分类 / 场景标签)→ 右侧卡片流(每排 5,图标 / 名称 / 简介 / 下载量)
- 分页:每页 100,上下翻页;每个技能 = `<article>` + JSON-LD
### 应用服务(Apps)
- 网格布局;每应用独立卡片(名称 / 介绍 / 「进入应用」按钮)
### 用户工作台(Workspace,登录后)
- 左侧固定导航:AI 聊天 / IM Bot(子:微信 / 企微 / 飞书 / QQ / 悟空 IM)/ 知识库 / 技能市场 / 应用服务 / Token 驾驶舱 / 会员订阅 / 进化看板 / 个人设置
- 右侧内容区留白;移动端导航转抽屉
### 后台数据驾驶舱(Admin Dashboard)
- 6 张 KPI 卡:总用户 / 7日活跃、模型统计(启用 / 全部)、对话统计(有效 / 活跃窗口)、知识库文档数、今日订阅(成功)、Token 消耗(今日 / 本周)
- 大模型消耗趋势(近 30 天 面积图)
- 操作日志 + 审计日志 双栏(每 20 条 / 页,可下载)
- 其余后台:AI 技能中心 / 平台运营 / 商业变现 / 系统管理(标准 B 端布局,侧栏分类清晰)
## 12. Agent Prompt Guide
> 当被要求"做/改一个七虎AI 的页面或组件"时,代理必须:
> 1. 先读本文件(DESIGN.md)与 `design-system/MASTER.md`;
> 2. 颜色/间距/圆角/字体一律引用 `:root` token,禁止新硬编码;
> 3. 品牌色固定 `#5B5FED`,语义色仅表状态;
> 4. 图标用 Lucide;代码/ID 用 JetBrains Mono;
> 5. 组件带 hover + focus + 150–300ms 过渡;
> 6. 完成后 `npm run build` 并确认 `dist` 含改动,再请用户硬刷新验收。
**快捷色板(供 prompt 引用)**
```
brand #5B5FED hover #4A4ECC light #EEEDFF
bg #F8F9FB surface #FFFFFF tertiary #F1F3F7
text #101214 secondary #545A65 tertiary #8B919E
ok #219B5E warn #E8922C err #D9383A info #3B82F6
border #E3E6EB sidebar #F5F6F9
font Inter / JetBrains Mono
grad --gradient-brand (淡紫→淡青) / --gradient-brand-strong (靛蓝→青蓝 CTA)
glass .glass (rgba(255,255,255,.65) + blur(16px) + 1px 白边)
kw .kw 高亮关键词(无限Token/AI变现/办公提效)
```
**推荐 prompt 模板**
- "在七虎AI 中新增一个 [页面],遵循 DESIGN.md 的 Warm Tech Minimalism 风格,品牌色用 #5B5FED,图标用 Lucide,间距用 token。"
- "把 [组件] 改成符合 DESIGN.md §4 的卡片规范,hover 用 --shadow-lg + translateY(-2px)。"
- "新增前台 [页面] 时遵循 DESIGN.md §10 的 SEO/GEO 规范:语义化 HTML5 + JSON-LD 富媒体摘要 + `.kw` 高亮核心词;Hero/Banner 可用 `.glass` 与 `--gradient-brand`。"
+14
View File
@@ -34,3 +34,17 @@
- 5d7f32d (2026-07-11): "refactor: sync running-container source back to repo ... remove 104 stale files" -
误删 104 个迭代开发文件,导致重建容器后丢失近期功能。根因:迭代只 docker cp 进容器、未回灌 git/镜像。
后续从 5d7f32d^ (误删前最后一个完整提交) 取回。教训:每次迭代必须走 freeze 闭环。
## v2026.07.13-recover — 确定性恢复(源=git HEAD=5d7f32d^ 规范树,逐文件哈希对齐)
- 时间: 2026-07-12 16:48
- 范围: 同步 git 完整规范源进运行容器,确定性重建前端 dist;后端+前端源码与 git HEAD 零偏差(149 文件逐 md5 比对一致)
- 含功能: ①大模型库按能力默认模型 ②前台 Auto 意图路由 ③AGNES 图像/视频模型 ④个人微信 ClawBot IM Bot ⑤技能市场改 URL/AI 导入 ⑥全站 UI 重构(Phase C1-R/C2/C3/C4)+浅色主题
- 回滚: git checkout v2026.07.13-recover && docker tag qihuaiflow:backup-v2026.07.13-recover qihuaiflow:with-deps && docker compose up -d
## v2026.07.12-ui-recover — UI 优化 + 微信 IM Bot 对话窗合并恢复
- 恢复来源:本地容器快照 `_container_live` + 顶层 `_*.tsx`(7/11 重建前从运行容器拉取)
- 恢复文件:ChatPage.tsx(IM Bot 对话窗合并)、MarketPage.tsx/css(技能市场UI)、
AdminLayout.tsx(全站UI优化)、Dashboard.tsx(全站UI优化)、api/index.ts(IM Bot API客户端)
- 根因:这些 UI/IM 改动只在容器里 docker cp,从未 commit git;`docker cp` 从 git 恢复时
用缺 UI 的 git 版本覆盖了容器里改好的 UI。
- 已回灌 git + 冻结镜像 v2026.07.12-ui-recover(=with-deps)。上一版备份 backup-v2026.07.13-preui。
+1 -29
View File
@@ -1,29 +1 @@
AIFlow v2.0.1
Build Date: 2026-06-02
Features: AI chat with real API, tool-call function calling, knowledge base file storage + AI-driven graph (LLM entity/relation extraction), channel QR codes, email/SMS config with AI content generation, payment config, admin dashboard (platform cockpit) with cumulative token stats, audit/operation logs, agent resources API, evolution pipeline with auto-recovery, global default model, ad banner image upload + AI image generation, app market install/uninstall + search + duplicate detection, asset file type classification + online preview
Migrations: 001-015 (15 total)
Fix: 015_production_fixes - knowledge_documents增加content列; AppPurchase状态在卸载时重置
Fix: 014_clean_test_data - 交付前清理Mock/测试数据,去重model_provider,保留admin账号及种子数据
Fix: 013_knowledge_graph_tables - 知识图谱节点/边/构建记录三张新表
Fix: 012_production_column_sync - 幂等补全生产环境可能缺失的列
Fix: run.sh admin密码智能检测 - 仅在哈希无效时重置,保护用户修改的密码
Fix: main.py lifespan - Evolution Pipeline自动恢复运行+chat.py自动启动Pipeline
Fix: Auth login - 前端区分用户名不存在和密码错误(/auth/login 401特殊处理)
Fix: Dashboard - 更名平台驾驶舱 + total_tokens累计统计
Fix: ChatPage - Agent/技能选择修复onClick交互 + used_skills关联请求skill_ids
Fix: Channels QR - 统一使用config.APP_ACCESS_URL(3处os.environ改为config)
Fix: Ad banners - 后端图片上传+AI绘图接口
Fix: Models - 全局默认LLM设置入口+is_default显示
Fix: Email config - 字段空值校验+测试前先保存+SMTP逐字段验证
Fix: docker-compose.yml - APP_ACCESS_URL改为https://bot.qihuyun.com(二维码localhost根因)
Fix: Token dashboard - 导出CSV下载+一键充值API+服务器时间同步
Fix: Knowledge graph - Canvas节点拖拽+悬停高亮tooltip+点击选中详情面板
Fix: Assets - 按ID去重+Office/PDF在线预览+文件类型映射修复
Fix: ChatPage - 知识库/Agent/技能多选组合(checkbox多选dropdown)
Feature: AstrBot借鉴 - 统一Webhook端点+消息组件系统+平台适配器注册+MessageSession+Pipeline管道+Redis事件总线
Refactor: 清理旧版_handle_*_webhook函数和_create_unified_event辅助函数(约330行冗余代码),统一通过适配器模式工作
Fix: unified_webhook - URL验证(challenge/url_verification)优先于频道存在性检查
Fix: config.py - APP_ACCESS_URL三级防护(检测临时域名自动覆盖为正式域名)
Fix: BUG-1 - 邮件/SMS/站点设置GET接口返回标准{code,data,message}格式,修复配置持久化后刷新丢失
Fix: BUG-2 - Token驾驶舱CSV导出分页获取(page_size=100) + 一键充值跳转/#/pricing
Fix: 生产环境6项缺陷修复 - (1)频道二维码add bind页面+后端API,(2)knowledge_documents add content列,(3)tool-call优先调用skills system prompt,(4)卸载时重置AppPurchase状态,(5)Token overview add server_time (Shanghai时区),(6)邮件测试add smtplib全异常捕获+标准返回格式
v2026.07.19.6
-1
View File
@@ -1,6 +1,5 @@
from . import auth
from . import chat
from . import channels
from . import knowledge
from . import market
from . import token_usage
File diff suppressed because it is too large. Load diff
+209 -40
View File
@@ -2,77 +2,258 @@
管理员仪表盘 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from ...database import get_db, SessionLocal
from ...models import (
User, AIModel, Order, TokenUsageLog, OperationLog, AuditLog
User, AIModel, Order, TokenUsageLog, OperationLog, AuditLog,
Conversation, Message, KnowledgeBase, KnowledgeDocument,
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from datetime import datetime, date
from datetime import datetime, date, timedelta, time
import csv
import io
router = APIRouter()
def _serialize_op_log(log):
return {
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"action": log.action,
"target": log.target,
"target_id": log.target_id,
"detail": log.detail,
"ip": log.ip,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
def _serialize_audit_log(log):
return {
"id": log.id,
"admin_id": log.admin_id,
"admin_name": log.admin_name,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
@router.get("/stats")
async def get_dashboard_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取管理员仪表盘统计数据"""
today = date.today()
today_start = datetime.combine(date.today(), time.min)
seven_days_ago = datetime.now() - timedelta(days=7)
active_users = db.query(func.count(User.id)).filter(
User.status == "active"
).scalar() or 0
total_users = db.query(func.count(User.id)).scalar() or 0
active_users = db.query(
func.count(func.distinct(Conversation.user_id))
).filter(Conversation.created_at >= seven_days_ago).scalar() or 0
total_models = db.query(func.count(AIModel.id)).scalar() or 0
enabled_models = db.query(func.count(AIModel.id)).filter(
AIModel.is_enabled == True
).scalar() or 0
total_conversations = db.query(func.count(Conversation.id)).scalar() or 0
active_conversations = db.query(func.count(Conversation.id)).filter(
Conversation.updated_at >= seven_days_ago
).scalar() or 0
valid_conversations = db.query(func.count(Conversation.id)).filter(
Conversation.id.in_(db.query(Message.conversation_id).distinct())
).scalar() or 0
total_knowledge_bases = db.query(func.count(KnowledgeBase.id)).scalar() or 0
total_documents = db.query(func.count(KnowledgeDocument.id)).scalar() or 0
today_orders = db.query(func.count(Order.id)).filter(
func.date(Order.created_at) == today
Order.status == "paid",
Order.paid_at >= today_start,
).scalar() or 0
today_subscription_orders = db.query(func.count(Order.id)).filter(
Order.order_type == "membership",
Order.status == "paid",
Order.paid_at >= today_start,
).scalar() or 0
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter(
func.date(TokenUsageLog.created_at) == today
today_conversations = db.query(func.count(Conversation.id)).filter(
Conversation.created_at >= today_start
).scalar() or 0
total_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).scalar() or 0
today_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).filter(
TokenUsageLog.created_at >= today_start
).scalar() or 0
week_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).filter(
TokenUsageLog.created_at >= seven_days_ago
).scalar() or 0
total_tokens = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).scalar() or 0
return {
"active_users": active_users,
"total_users": total_users,
"active_users": int(active_users),
"total_models": total_models,
"enabled_models": enabled_models,
"total_conversations": total_conversations,
"active_conversations": int(active_conversations),
"valid_conversations": int(valid_conversations),
"total_knowledge_bases": total_knowledge_bases,
"total_documents": total_documents,
"today_orders": today_orders,
"today_subscription_orders": today_subscription_orders,
"today_conversations": today_conversations,
"today_tokens": int(today_tokens),
"week_tokens": int(week_tokens),
"total_tokens": int(total_tokens),
"server_time": datetime.now().isoformat(),
}
@router.get("/daily-stats")
async def get_daily_token_stats(
days: int = Query(30, ge=1, le=365),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取近 N 天 Token 消耗趋势(按天聚合)"""
end = date.today()
start = end - timedelta(days=days - 1)
start_dt = datetime.combine(start, time.min)
rows = db.query(
func.date(TokenUsageLog.created_at),
func.coalesce(func.sum(TokenUsageLog.total_tokens), 0),
).filter(TokenUsageLog.created_at >= start_dt).group_by(
func.date(TokenUsageLog.created_at)
).all()
m = {str(r[0]): int(r[1] or 0) for r in rows}
data = []
for i in range(days):
d = start + timedelta(days=i)
ds = d.isoformat()
data.append({"date": ds, "tokens": m.get(ds, 0)})
return {"code": 200, "data": {"days": days, "data": data}}
@router.get("/operation-logs")
async def list_operation_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""分页获取操作日志(默认 20 条/页)"""
total = db.query(func.count(OperationLog.id)).scalar() or 0
items = db.query(OperationLog).order_by(
OperationLog.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 200,
"data": {
"items": [_serialize_op_log(l) for l in items],
"total": total,
"page": page,
"page_size": page_size,
},
}
@router.get("/audit-logs")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""分页获取审计日志(默认 20 条/页)"""
total = db.query(func.count(AuditLog.id)).scalar() or 0
items = db.query(AuditLog).order_by(
AuditLog.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 200,
"data": {
"items": [_serialize_audit_log(l) for l in items],
"total": total,
"page": page,
"page_size": page_size,
},
}
@router.get("/operation-logs/export")
async def export_operation_logs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出全部操作日志为 CSV"""
logs = db.query(OperationLog).order_by(OperationLog.created_at.desc()).all()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["时间", "用户", "操作", "目标", "目标ID", "详情", "IP"])
for l in logs:
writer.writerow([
l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else "",
l.username or "",
l.action or "",
l.target or "",
l.target_id or "",
(l.detail or "").replace("\n", " ").replace("\r", ""),
l.ip or "",
])
content = buf.getvalue().encode("utf-8-sig")
return StreamingResponse(
io.BytesIO(content),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=operation_logs.csv"},
)
@router.get("/audit-logs/export")
async def export_audit_logs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出全部审计日志为 CSV"""
logs = db.query(AuditLog).order_by(AuditLog.created_at.desc()).all()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["时间", "操作人", "操作类型", "目标类型", "目标ID", "详情"])
for l in logs:
writer.writerow([
l.created_at.strftime("%Y-%m-%d %H:%M:%S") if l.created_at else "",
l.admin_name or "",
l.action or "",
l.target_type or "",
l.target_id or "",
(l.detail or "").replace("\n", " ").replace("\r", ""),
])
content = buf.getvalue().encode("utf-8-sig")
return StreamingResponse(
io.BytesIO(content),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit_logs.csv"},
)
@router.get("/recent-logs")
async def get_recent_logs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取最近10条操作日志"""
"""获取最近10条操作日志(兼容旧调用)"""
logs = db.query(OperationLog).order_by(
OperationLog.created_at.desc()
).limit(10).all()
return [
{
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"action": log.action,
"target": log.target,
"target_id": log.target_id,
"detail": log.detail,
"ip": log.ip,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
return [_serialize_op_log(log) for log in logs]
@router.get("/recent-audit")
@@ -80,20 +261,8 @@ async def get_recent_audit(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取最近10条审计日志"""
"""获取最近10条审计日志(兼容旧调用)"""
logs = db.query(AuditLog).order_by(
AuditLog.created_at.desc()
).limit(10).all()
return [
{
"id": log.id,
"admin_id": log.admin_id,
"admin_name": log.admin_name,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
return [_serialize_audit_log(log) for log in logs]
+1 -1
View File
@@ -608,7 +608,7 @@ def _qihua_reply(text: str) -> str:
自行创建 DB 会话,避免上层线程无可用 session 时崩溃。"""
db = None
try:
from app.api.channels import _call_real_llm
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, text, "个人微信")
except Exception as e:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,103 @@
"""调用可观测面板后端 API(媒体/LLM 调用归因)。
路由前缀: /app/api/admin/observability
数据来源: token_usage_logs(Phase 2.2 已加 trace_id 列,可经 X-Trace-Id 关联请求链路)。
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, Depends
from ...database import get_db
from ..auth import get_current_user
from sqlalchemy import text
from sqlalchemy.orm import Session
router = APIRouter()
@router.get("/summary")
async def observability_summary(
days: int = Query(30, description="统计天数"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""媒体/LLM 调用归因:总览 + 按模型 + 按用户 + 每日趋势 + 最近调用(trace_id 下钻)。"""
since = datetime.now() - timedelta(days=days)
totals = db.execute(text("""
SELECT COUNT(*),
COALESCE(SUM(total_tokens), 0),
COALESCE(SUM(cost), 0),
COUNT(DISTINCT user_id)
FROM token_usage_logs WHERE created_at >= :since
"""), {"since": since}).fetchone()
by_model = db.execute(text("""
SELECT model_name, COUNT(*), COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY model_name ORDER BY COALESCE(SUM(total_tokens), 0) DESC
"""), {"since": since}).fetchall()
by_user = db.execute(text("""
SELECT t.user_id, u.username, COUNT(*), COALESCE(SUM(t.total_tokens), 0), COALESCE(SUM(t.cost), 0)
FROM token_usage_logs t LEFT JOIN users u ON u.id = t.user_id
WHERE t.created_at >= :since
GROUP BY t.user_id, u.username ORDER BY COALESCE(SUM(t.total_tokens), 0) DESC LIMIT 20
"""), {"since": since}).fetchall()
daily = db.execute(text("""
SELECT DATE(created_at) AS d, COALESCE(SUM(total_tokens), 0), COALESCE(SUM(cost), 0), COUNT(*)
FROM token_usage_logs WHERE created_at >= :since
GROUP BY DATE(created_at) ORDER BY d
"""), {"since": since}).fetchall()
recent = db.execute(text("""
SELECT trace_id, model_name, total_tokens, cost, created_at
FROM token_usage_logs WHERE created_at >= :since
ORDER BY created_at DESC LIMIT 50
"""), {"since": since}).fetchall()
return {
"totals": {
"calls": int(totals[0] or 0),
"tokens": int(totals[1] or 0),
"cost": float(totals[2] or 0),
"users": int(totals[3] or 0),
},
"by_model": [
{
"model_name": m[0] or "unknown",
"calls": int(m[1] or 0),
"tokens": int(m[2] or 0),
"cost": float(m[3] or 0),
}
for m in by_model
],
"by_user": [
{
"user_id": u[0],
"username": u[1] or u[0],
"calls": int(u[2] or 0),
"tokens": int(u[3] or 0),
"cost": float(u[4] or 0),
}
for u in by_user
],
"daily": [
{
"date": str(d[0]),
"tokens": int(d[1] or 0),
"cost": float(d[2] or 0),
"calls": int(d[3] or 0),
}
for d in daily
],
"recent": [
{
"trace_id": r[0],
"model_name": r[1] or "unknown",
"tokens": int(r[2] or 0),
"cost": float(r[3] or 0),
"created_at": r[4].strftime("%Y-%m-%d %H:%M:%S") if r[4] else None,
}
for r in recent
],
}
File diff suppressed because it is too large. Load diff
+238 -74
View File
@@ -11,12 +11,13 @@ from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func, or_, and_
from sqlalchemy import desc, func, or_, and_, text
from ...database import get_db
from ...models import (
User, EvolutionSkill, SkillVersion, SkillPublishRecord,
EvolutionEvent, TeamSharedSkill
EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation,
AppPurchase, AppReview
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
@@ -53,6 +54,41 @@ SKILL_CATEGORIES = [
# 辅助函数
# ============================================================
def market_app_to_skill_dict(app: "MarketApp") -> dict:
"""将市场应用(MarketApp type=skill)转换为与 EvolutionSkill 一致的字典格式"""
status = (getattr(app, "status", None) or "pending").strip()
state = "main" if status == "published" else "baby"
raw_tags = getattr(app, "tags", None)
tags_list = []
if raw_tags:
try:
tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags)
except Exception:
tags_list = [t.strip() for t in str(raw_tags).split(",")]
category = getattr(app, "category", None) or "other"
return {
"id": app.id,
"user_id": getattr(app, "publisher_id", None) or "system",
"skill_name": app.name,
"description": getattr(app, "description", None) or "",
"tags": tags_list,
"category": category,
"category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == category), category or "其他"),
"state": state,
"state_info": SKILL_STATES.get(state, SKILL_STATES["baby"]),
"use_count": getattr(app, "install_count", None) or 0,
"version": 1,
"skill_md_path": None,
"git_commit_sha": None,
"last_used_at": None,
"created_at": app.created_at.isoformat() if getattr(app, "created_at", None) else None,
"updated_at": getattr(app, "updated_at", None).isoformat() if getattr(app, "updated_at", None) else None,
"source": "market",
# 让前端 isPublished(skill) 能正确识别 market 已发布项
"publish_records": [{"status": "published"}] if status == "published" else [],
}
def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict:
"""将技能ORM对象转换为字典"""
result = {
@@ -117,59 +153,71 @@ async def list_skills(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取全平台技能列表(支持分页/过滤/搜索)"""
"""获取全平台技能列表(支持分页/过滤/搜索)—— 同时查询 EvolutionSkill + MarketApp(type=skill)"""
logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}")
query = db.query(EvolutionSkill)
result: list = []
# ===== 1) 查询 EvolutionSkill =====
evo_query = db.query(EvolutionSkill)
if state:
query = query.filter(EvolutionSkill.state == state)
evo_query = evo_query.filter(EvolutionSkill.state == state)
if category:
query = query.filter(EvolutionSkill.category == category)
evo_query = evo_query.filter(EvolutionSkill.category == category)
if keyword:
query = query.filter(
or_(
EvolutionSkill.skill_name.contains(keyword),
EvolutionSkill.description.contains(keyword)
)
evo_query = evo_query.filter(
or_(EvolutionSkill.skill_name.contains(keyword), EvolutionSkill.description.contains(keyword))
)
if user_id:
query = query.filter(EvolutionSkill.user_id == user_id)
evo_query = evo_query.filter(EvolutionSkill.user_id == user_id)
# 总数
total = query.count()
# 排序
sort_field = getattr(EvolutionSkill, sort_by, EvolutionSkill.updated_at)
if order == "desc":
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
# 分页
skills = query.offset((page - 1) * page_size).limit(page_size).all()
# 补充用户名
user_ids = list(set(s.user_id for s in skills))
users_map = {
u.id: u.username
for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()
}
result = []
for s in skills:
evo_skills = evo_query.all()
user_ids = list(set(s.user_id for s in evo_skills))
users_map = {u.id: u.username for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()}
for s in evo_skills:
item = skill_to_dict(s)
item["owner_username"] = users_map.get(s.user_id, s.user_id)
item["source"] = "evolution"
result.append(item)
# ===== 2) 查询 MarketApp (type=skill) =====
mkt_query = db.query(MarketApp).filter(MarketApp.type == "skill")
if keyword:
mkt_query = mkt_query.filter(
or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword))
)
if category:
mkt_query = mkt_query.filter(MarketApp.category == category)
# 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby'
if state == "main":
mkt_query = mkt_query.filter(MarketApp.status == "published")
elif state:
mkt_query = mkt_query.filter(MarketApp.status != "published") # 非发布=草稿等
market_apps_rows = mkt_query.all()
for app in market_apps_rows:
item = market_app_to_skill_dict(app)
item["owner_username"] = app.publisher_name or "system"
result.append(item)
# ===== 3) 合并排序 & 分页 =====
total = len(result)
reverse = order == "desc"
key_fn = lambda x: x.get(sort_by) or ""
result.sort(key=key_fn, reverse=reverse)
start = (page - 1) * page_size
end = start + page_size
paged = result[start:end]
return {
"code": 0,
"data": {
"items": result,
"items": paged,
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
"pages": (total + page_size - 1) // page_size if total else 0,
}
}
@@ -429,7 +477,55 @@ async def update_skill(
# ============================================================
# 删除技能
# 批量删除技能(用于一次性清空技能市场)
# ============================================================
class BatchDeleteSkillsRequest(BaseModel):
ids: list[str] = [] # 要删除的技能 ID 列表;传空列表则删除所有
@router.post("/batch-delete")
async def batch_delete_skills(
req: BatchDeleteSkillsRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""批量删除技能。ids 为空则删除全部技能。"""
if req.ids:
skills = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.ids)).all()
else:
skills = db.query(EvolutionSkill).all()
deleted_count = 0
for skill in skills:
skill_name = skill.skill_name
# 删除版本历史
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
# 删除发布记录
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill.id).delete()
# 删除团队共享
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill.id).delete()
# 同步清理市场应用表中的技能
market_app = db.query(MarketApp).filter(
MarketApp.type == "skill",
MarketApp.name == skill_name,
).first()
if market_app:
# 清理关联的安装和购买记录
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
db.delete(market_app)
db.delete(skill)
deleted_count += 1
db.commit()
return {"code": 0, "data": {"deleted": deleted_count}, "message": f"已删除 {deleted_count} 个技能"}
# ============================================================
# 删除技能(单个)
# ============================================================
@router.delete("/{skill_id}")
@@ -438,45 +534,82 @@ async def delete_skill(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除技能"""
"""删除技能(支持 EvolutionSkill 和 MarketApp 两种来源)"""
# 1) 先尝试 EvolutionSkill
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
if not skill:
raise HTTPException(status_code=404, detail="技能不存在")
if skill:
skill_name = skill.skill_name
user_id = skill.user_id
skill_name = skill.skill_name
user_id = skill.user_id
# 记录事件
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=user_id,
skill_name=skill_name,
event_type="deleted",
description=f"管理员 {admin_user.username} 删除技能 {skill_name}",
)
db.add(ev)
# 记录事件
ev = EvolutionEvent(
id=str(uuid.uuid4()),
user_id=user_id,
skill_name=skill_name,
event_type="deleted",
description=f"管理员 {admin_user.username} 删除技能 {skill_name}",
)
db.add(ev)
# 删除版本历史
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
# 删除版本历史
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
# 删除发布记录
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete()
# 删除发布记录
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete()
# 删除团队共享记录
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete()
# 删除团队共享记录
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete()
# 同步清理市场应用表中的同名技能
market_app = db.query(MarketApp).filter(
MarketApp.type == "skill",
MarketApp.name == skill_name,
).first()
if market_app:
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
db.delete(market_app)
db.delete(skill)
db.commit()
db.delete(skill)
db.commit()
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="delete_skill",
target="evolution_skills",
detail=f"删除技能 {skill_name}",
)
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="delete_skill",
target="evolution_skills",
detail=f"删除技能 {skill_name}",
)
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
# 2) 再尝试 MarketApp 中导入的技能(type=skill)
market_app = db.query(MarketApp).filter(
MarketApp.type == "skill",
MarketApp.id == skill_id,
).first()
if market_app:
skill_name = market_app.name
# 清理关联记录
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
db.delete(market_app)
db.commit()
LogService.log_operation(
user_id=admin_user.id,
username=admin_user.username,
action="delete_skill",
target="market_apps",
detail=f"删除市场技能 {skill_name}",
)
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
# 3) 都未找到
raise HTTPException(status_code=404, detail="技能不存在")
# ============================================================
@@ -745,27 +878,58 @@ async def get_skills_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取技能统计概览"""
total = db.query(EvolutionSkill).count()
by_state = dict(
"""获取技能统计概览(EvolutionSkill + MarketApp type=skill 合并统计)"""
# EvolutionSkill 维度
evo_total = db.query(EvolutionSkill).count()
evo_by_state = dict(
db.query(EvolutionSkill.state, func.count(EvolutionSkill.id))
.group_by(EvolutionSkill.state).all()
)
by_category = dict(
evo_by_category = dict(
db.query(EvolutionSkill.category, func.count(EvolutionSkill.id))
.filter(EvolutionSkill.category.isnot(None))
.group_by(EvolutionSkill.category).all()
)
total_published = db.query(SkillPublishRecord).filter(
# MarketApp(type=skill) 维度:状态映射 published→main, 其它→baby
market_total = db.query(MarketApp).filter(MarketApp.type == "skill").count()
market_rows = db.query(MarketApp.status, func.count(MarketApp.id)).filter(
MarketApp.type == "skill"
).group_by(MarketApp.status).all()
market_published = sum(cnt for status, cnt in market_rows if (status or "") == "published")
# 各 category 计数(market 表 category 在 SQL 列有,ORM 无此属性,用 SQL 拿)
market_by_category = {}
try:
market_by_category_rows = db.execute(
text("SELECT category, COUNT(*) FROM market_apps WHERE type='skill' AND category IS NOT NULL GROUP BY category")
).fetchall()
market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows}
except Exception:
pass
# 合并
total_skills = evo_total + market_total
by_state = {k: evo_by_state.get(k, 0) for k in SKILL_STATES}
by_state["main"] = by_state.get("main", 0) + market_published
by_state["baby"] = by_state.get("baby", 0) + (market_total - market_published)
by_category = {}
for c in (evo_by_category or {}).items():
by_category[c[0]] = by_category.get(c[0], 0) + c[1]
for c in (market_by_category or {}).items():
by_category[c[0]] = by_category.get(c[0], 0) + c[1]
# 原始发布记录 + market 已发布 = 全部已发布
skill_record_published = db.query(SkillPublishRecord).filter(
SkillPublishRecord.status == "published"
).count()
total_published = skill_record_published + market_published
total_versions = db.query(SkillVersion).count()
return {
"code": 0,
"data": {
"total_skills": total,
"by_state": {k: by_state.get(k, 0) for k in SKILL_STATES},
"total_skills": total_skills,
"by_state": by_state,
"by_category": by_category,
"total_published": total_published,
"total_versions": total_versions,
+32 -41
View File
@@ -121,57 +121,48 @@ async def get_register_settings(
@router.put("/register-settings")
async def update_register_settings(
phone_required: bool = None,
phone_sms_required: bool = None,
email_required: bool = None,
email_verify_required: bool = None,
real_name_required: bool = None,
aliyun_access_key: str = None,
aliyun_secret_key: str = None,
aliyun_endpoint: str = None,
sms_sign_name: str = None,
sms_template_code: str = None,
body: dict,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新注册设置"""
"""更新注册设置(接收 JSON Body)"""
setting = db.query(RegisterSetting).first()
if not setting:
setting = RegisterSetting(
id=str(uuid.uuid4()),
phone_required=phone_required if phone_required is not None else True,
phone_sms_required=phone_sms_required if phone_sms_required is not None else True,
email_required=email_required if email_required is not None else True,
email_verify_required=email_verify_required if email_verify_required is not None else True,
real_name_required=real_name_required or False,
aliyun_access_key=aliyun_access_key,
aliyun_secret_key=aliyun_secret_key,
aliyun_endpoint=aliyun_endpoint,
sms_sign_name=sms_sign_name,
sms_template_code=sms_template_code,
phone_required=body.get("phone_required", True),
phone_sms_required=body.get("phone_sms_required", True),
email_required=body.get("email_required", True),
email_verify_required=body.get("email_verify_required", True),
real_name_required=body.get("real_name_required", False),
aliyun_access_key=body.get("aliyun_access_key"),
aliyun_secret_key=body.get("aliyun_secret_key"),
aliyun_endpoint=body.get("aliyun_endpoint"),
sms_sign_name=body.get("sms_sign_name"),
sms_template_code=body.get("sms_template_code"),
)
db.add(setting)
else:
if phone_required is not None:
setting.phone_required = phone_required
if phone_sms_required is not None:
setting.phone_sms_required = phone_sms_required
if email_required is not None:
setting.email_required = email_required
if email_verify_required is not None:
setting.email_verify_required = email_verify_required
if real_name_required is not None:
setting.real_name_required = real_name_required
if aliyun_access_key is not None:
setting.aliyun_access_key = aliyun_access_key
if aliyun_secret_key is not None:
setting.aliyun_secret_key = aliyun_secret_key
if aliyun_endpoint is not None:
setting.aliyun_endpoint = aliyun_endpoint
if sms_sign_name is not None:
setting.sms_sign_name = sms_sign_name
if sms_template_code is not None:
setting.sms_template_code = sms_template_code
if "phone_required" in body:
setting.phone_required = body["phone_required"]
if "phone_sms_required" in body:
setting.phone_sms_required = body["phone_sms_required"]
if "email_required" in body:
setting.email_required = body["email_required"]
if "email_verify_required" in body:
setting.email_verify_required = body["email_verify_required"]
if "real_name_required" in body:
setting.real_name_required = body["real_name_required"]
if "aliyun_access_key" in body:
setting.aliyun_access_key = body["aliyun_access_key"]
if "aliyun_secret_key" in body:
setting.aliyun_secret_key = body["aliyun_secret_key"]
if "aliyun_endpoint" in body:
setting.aliyun_endpoint = body["aliyun_endpoint"]
if "sms_sign_name" in body:
setting.sms_sign_name = body["sms_sign_name"]
if "sms_template_code" in body:
setting.sms_template_code = body["sms_template_code"]
setting.updated_at = datetime.now()
db.commit()
+13
View File
@@ -368,6 +368,19 @@ def delete_asset(
asset.status = "deleted"
asset.updated_at = datetime.now()
# 若资产由知识库文档同步而来,双向删除对应的知识库文档
if asset.knowledge_doc_id:
try:
kb_doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == asset.knowledge_doc_id).first()
if kb_doc:
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_doc.knowledge_base_id).first()
if kb:
kb.document_count = max(0, (kb.document_count or 0) - 1)
kb.updated_at = datetime.now()
db.delete(kb_doc)
except Exception:
pass
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="delete",
target="asset", target_id=asset.id,
+113 -2
View File
@@ -4,15 +4,18 @@ from sqlalchemy import and_
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
import random
import re
from datetime import datetime, timedelta
from ..database import get_db
from ..models import (
User, LoginLog, RegisterSetting,
User, LoginLog, RegisterSetting, SmsConfig, SmsVerificationCode,
gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password, create_access_token
from ..middleware.log_service import LogService
from ..sms_client import send_sms_code
router = APIRouter()
@@ -27,6 +30,18 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=6, max_length=100)
email: Optional[str] = None
phone: Optional[str] = None
sms_code: Optional[str] = None
class SendCodeRequest(BaseModel):
phone: str
purpose: str = "register" # register / reset_password
class ResetPasswordRequest(BaseModel):
phone: str
code: str
new_password: str = Field(min_length=6, max_length=100)
@router.post("/login")
@@ -117,6 +132,20 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
if existing_phone:
raise HTTPException(status_code=400, detail="手机号已被注册")
# 短信验证码校验(手机号注册且开启短信验证)
if req.phone and reg_setting and reg_setting.phone_sms_required:
if not req.sms_code:
raise HTTPException(status_code=400, detail="请先获取并填写短信验证码")
vrec = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == "register",
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if not vrec or vrec.code != req.sms_code:
raise HTTPException(status_code=400, detail="短信验证码错误或已过期")
vrec.used = True
user = User(
id=gen_uuid(),
username=req.username,
@@ -179,6 +208,88 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
}
@router.post("/send-code")
def send_code(req: SendCodeRequest, db: Session = Depends(get_db)):
"""发送短信验证码(注册 / 重置密码)。公开接口。"""
if not re.match(r'^1[3-9]\d{9}$', req.phone or ''):
raise HTTPException(status_code=400, detail="手机号格式不正确")
purpose = req.purpose or "register"
if purpose not in ("register", "reset_password"):
raise HTTPException(status_code=400, detail="不支持的验证码类型")
config = db.query(SmsConfig).first()
if not config or not config.provider:
raise HTTPException(status_code=400, detail="短信服务未配置,请联系管理员")
# 注册场景:手机号已注册则直接提示,避免浪费短信
if purpose == "register":
existing = db.query(User).filter(User.phone == req.phone).first()
if existing:
raise HTTPException(status_code=400, detail="该手机号已注册,请直接登录")
# 频率限制:同一手机号+用途 60 秒内不重复下发
recent = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == purpose,
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if recent:
age = (datetime.now() - recent.created_at).total_seconds()
if age < 60:
raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{int(60 - age)}秒后再试")
code = f"{random.randint(100000, 999999)}"
rec = SmsVerificationCode(
id=gen_uuid(),
phone=req.phone,
code=code,
purpose=purpose,
expires_at=datetime.now() + timedelta(minutes=5),
used=False,
)
db.add(rec)
db.commit()
ok, msg, _ = send_sms_code(config, req.phone, code, purpose)
if not ok:
raise HTTPException(status_code=400, detail=f"短信发送失败:{msg}")
return {"code": 0, "data": {"sent": True}, "message": "验证码已发送"}
@router.post("/reset-password")
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_db)):
"""通过短信验证码重置密码(公开接口)。"""
user = db.query(User).filter(User.phone == req.phone).first()
if not user:
raise HTTPException(status_code=400, detail="该手机号未注册")
vrec = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == "reset_password",
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if not vrec or vrec.code != req.code:
raise HTTPException(status_code=400, detail="验证码错误或已过期")
user.password_hash = hash_password(req.new_password)
user.updated_at = datetime.now()
vrec.used = True
LogService.log_operation(
user_id=user.id,
username=user.username,
action="reset_password",
target="user",
detail=f"用户 {user.username} 通过短信验证码重置了密码",
)
db.commit()
return {"code": 0, "message": "密码重置成功"}
@router.get("/me")
def get_me(current_user: User = Depends(get_current_user)):
return {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+951
View File
@@ -0,0 +1,951 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from app.core.credential import decrypt_secret
from app.core.observability import get_trace_id
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import (_extract_text_from_json, _proxy_for_url)
def _normalize_dify_chat_url(endpoint: str) -> str:
"""把用户填写的 Dify 地址归一化为 /v1/chat-messages 端点。与 market.py 保持一致。"""
ep = (endpoint or "").rstrip("/")
if ep.endswith("/chat-messages"):
return ep
if ep.endswith("/v1"):
return ep + "/chat-messages"
return ep + "/v1/chat-messages"
async def _call_dify_agent(endpoint: str, api_key: str, query: str,
user_id: str, ext_conv_id: str | None) -> tuple:
"""调用 Dify(新版 Agent) /v1/chat-messages,流式(SSE)解析最终回答。
新版 Agent API 仅支持 response_mode=streaming(blocking 会 400)。事件流中
message / agent_message 事件携带增量 answer,累加即为最终回答;每个事件都带
conversation_id,用于维持多轮上下文。返回 (answer, new_conversation_id)。
"""
url = _normalize_dify_chat_url(endpoint)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"inputs": {},
"query": query,
"user": (user_id or "web-user")[:64],
"response_mode": "streaming",
"conversation_id": ext_conv_id or "",
}
answer_parts = []
new_conv_id = ext_conv_id
err_msg = None
wf_outputs = None
# httpx 默认 follow_redirects=False;stream POST 若遇 301/302 会直接返回该状态码。
# 且 httpx 自动跟随会把 POST 降级为 GET(对 /v1/chat-messages 变 405)。
# 因此手动跟随 301/302/307/308,保持 POST 与请求体不变(最多 4 跳)。
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False,
follow_redirects=False) as client:
cur_url = url
for _redir in range(4):
async with client.stream("POST", cur_url, headers=headers, json=payload) as resp:
if resp.status_code in (301, 302, 307, 308):
loc = resp.headers.get("location") or resp.headers.get("Location")
await resp.aclose()
if not loc:
raise RuntimeError(f"Dify HTTP {resp.status_code}: 缺少重定向地址")
# 处理相对地址
if loc.startswith("/"):
from urllib.parse import urlsplit, urlunsplit
p = urlsplit(cur_url)
loc = urlunsplit((p.scheme, p.netloc, loc, "", ""))
cur_url = loc
continue
if resp.status_code != 200:
body = (await resp.aread()).decode("utf-8", "replace")
raise RuntimeError(f"Dify HTTP {resp.status_code}: {body[:300]}")
async for line in resp.aiter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
evt = json.loads(data_str)
except Exception:
continue
ev = evt.get("event")
if evt.get("conversation_id"):
new_conv_id = evt["conversation_id"]
data = evt.get("data") or {}
# 兼容三类 Dify 应用:
# 1) Chat/Agent 应用: message / agent_message 事件带增量 answer
# 2) Chatflow/Workflow 应用: text_chunk 事件带 data.text;
# 结束时 workflow_finished 带 status / outputs / error
if ev in ("message", "agent_message"):
answer_parts.append(evt.get("answer", "") or "")
elif ev == "message_replace":
# 内容审查替换:以替换后的完整 answer 为准
answer_parts = [evt.get("answer", "") or ""]
elif ev == "text_chunk":
answer_parts.append((data.get("text") or ""))
elif ev == "node_finished":
# answer / end 节点的 outputs 可能含最终文本
if data.get("node_type") in ("answer", "end") and data.get("outputs"):
wf_outputs = data.get("outputs")
if data.get("status") == "failed" and data.get("error"):
err_msg = str(data.get("error"))
elif ev == "workflow_finished":
if data.get("status") == "failed":
err_msg = str(data.get("error") or "Dify 工作流执行失败")
elif data.get("outputs"):
wf_outputs = data.get("outputs")
elif ev == "error":
err_msg = evt.get("message") or evt.get("code") or "Dify 流式返回错误"
break
else:
raise RuntimeError("Dify 重定向次数过多(>4),请直接填写重定向后的地址")
answer = "".join(answer_parts).strip()
# 流式无增量文本时,尝试从 workflow 结束 outputs 兜底提取
if not answer and wf_outputs:
answer = _extract_text_from_json(wf_outputs).strip()
if not answer and err_msg:
raise RuntimeError(err_msg)
return answer, new_conv_id
async def _call_n8n_agent(endpoint: str, api_key: str, query: str,
user_id: str, history: list) -> tuple:
"""调用 n8n 生产 Webhook (POST)。webhook 用 Respond to Webhook 节点返回 JSON,
尽力从中抽取文本回答。n8n webhook 无平台级会话态,多轮靠我们下发 history。
返回 (answer, None)。"""
url = (endpoint or "").strip()
headers = {"Content-Type": "application/json"}
if api_key:
# n8n Header Auth 自定义头名不固定,同时下发常见几种以提高兼容性
headers["Authorization"] = f"Bearer {api_key}"
headers["X-N8N-API-KEY"] = api_key
# 携带多种字段别名,兼容 n8n AI Agent(chatInput) 与自定义工作流
payload = {
"query": query,
"chatInput": query,
"message": query,
"input": query,
"user": (user_id or "web-user")[:64],
"history": history or [],
}
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code not in (200, 201):
raise RuntimeError(f"n8n Webhook HTTP {resp.status_code}: {resp.text[:300]}")
ctype = resp.headers.get("content-type", "")
if "application/json" in ctype:
try:
answer = _extract_text_from_json(resp.json())
except Exception:
answer = resp.text
else:
answer = resp.text
return (answer or "").strip(), None
async def _call_coze_agent(endpoint: str, api_key: str, query: str,
user_id: str, ext_conv_id: str | None) -> tuple:
"""调用扣子(Coze) v3 对话 API:create → 轮询 retrieve → 拉取 message list。
约定 agent_endpoint 存放 bot_id(纯 id),或形如
https://api.coze.cn/v3/chat?bot_id=xxx 的完整地址。默认基址 api.coze.cn。
返回 (answer, conversation_id)。
"""
ep = (endpoint or "").strip()
from urllib.parse import urlparse, parse_qs
bot_id = None
base = "https://api.coze.cn"
if ep.startswith("http"):
pr = urlparse(ep)
base = f"{pr.scheme}://{pr.netloc}"
qs = parse_qs(pr.query)
bot_id = (qs.get("bot_id") or [None])[0]
if not bot_id and "/bot/" in pr.path:
bot_id = pr.path.rstrip("/").split("/bot/")[-1]
else:
bot_id = ep # 纯 bot_id
if not bot_id:
raise RuntimeError("Coze 接入缺少 bot_id(请在 Endpoint 填入 bot_id 或带 ?bot_id= 的地址)")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
create_url = f"{base}/v3/chat"
if ext_conv_id:
create_url += f"?conversation_id={ext_conv_id}"
payload = {
"bot_id": bot_id,
"user_id": (user_id or "web-user")[:64],
"stream": False,
"auto_save_history": True,
"additional_messages": [
{"role": "user", "content": query, "content_type": "text"}
],
}
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(create_url), trust_env=False) as client:
r = await client.post(create_url, headers=headers, json=payload)
if r.status_code != 200:
raise RuntimeError(f"Coze create HTTP {r.status_code}: {r.text[:300]}")
cd = r.json()
if cd.get("code") not in (0, None):
raise RuntimeError(f"Coze 错误: {cd.get('msg') or cd}")
chat = cd.get("data") or {}
chat_id = chat.get("id")
conv_id2 = chat.get("conversation_id") or ext_conv_id
if not chat_id or not conv_id2:
raise RuntimeError(f"Coze 未返回 chat_id/conversation_id: {str(cd)[:200]}")
# 轮询直到完成(最多约 3 分钟)
status = chat.get("status")
for _ in range(60):
if status == "completed":
break
if status in ("failed", "requires_action", "canceled"):
raise RuntimeError(f"Coze 对话状态 {status}")
await asyncio.sleep(3)
rr = await client.get(
f"{base}/v3/chat/retrieve?chat_id={chat_id}&conversation_id={conv_id2}",
headers=headers,
)
if rr.status_code == 200:
status = ((rr.json() or {}).get("data") or {}).get("status")
# 拉取消息列表,取 type=answer 的内容
rm = await client.get(
f"{base}/v3/chat/message/list?chat_id={chat_id}&conversation_id={conv_id2}",
headers=headers,
)
answer = ""
if rm.status_code == 200:
for m in ((rm.json() or {}).get("data") or []):
if m.get("type") == "answer" and m.get("role") == "assistant":
answer = m.get("content", "") or answer
return (answer or "").strip(), conv_id2
async def _call_external_agent(db, agent: dict, query: str, user_id: str,
ext_conversation_id: str | None,
history: list) -> tuple:
"""按平台分发到对应外部 Agent 远程调用。返回 (answer, new_ext_conversation_id)。"""
platform = (agent.get("platform") or "dify").lower()
endpoint = agent.get("agent_endpoint")
_row = db.query(MarketApp).filter(MarketApp.id == agent.get("id")).first()
_raw = _row.agent_api_key if _row else None
try:
api_key = decrypt_secret(_raw) or ""
except Exception as _e:
api_key = ""
logging.error("[credential] 解密 Agent 凭据失败 id=%s trace=%s: %s" % (agent.get("id"), get_trace_id(), _e))
if not endpoint:
raise RuntimeError("该 Agent 未配置远程调用地址(agent_endpoint)")
if platform == "dify":
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
if platform == "n8n":
return await _call_n8n_agent(endpoint, api_key, query, user_id, history)
if platform == "coze":
return await _call_coze_agent(endpoint, api_key, query, user_id, ext_conversation_id)
# 未知平台:默认按 Dify 协议尝试
return await _call_dify_agent(endpoint, api_key, query, user_id, ext_conversation_id)
File diff suppressed because it is too large. Load diff
+319
View File
@@ -0,0 +1,319 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import (cst_now)
def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict:
"""从知识库文档构建知识图谱节点和边,返回 graph_data。"""
documents = db.query(KnowledgeDocument).filter(
and_(
KnowledgeDocument.knowledge_base_id == kb.id,
KnowledgeDocument.status == "completed",
)
).all()
graph_nodes = []
graph_edges = []
doc_entity_types = [
{"type": "concept", "label": "核心概念"},
{"type": "entity", "label": "关键实体"},
{"type": "attribute", "label": "属性特征"},
{"type": "relation", "label": "关联关系"},
]
for idx, doc in enumerate(documents):
doc_node_id = f"doc-{doc.id[:8]}"
graph_nodes.append({
"id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document",
})
filename_base = doc.filename.rsplit(".", 1)[0]
words = filename_base.replace("_", " ").replace("-", " ").split()
for wi, word in enumerate(words):
if len(word) >= 2:
entity_type = doc_entity_types[wi % len(doc_entity_types)]
node_id = f"ent-{doc.id[:8]}-{wi}"
graph_nodes.append({
"id": node_id, "label": word, "type": entity_type["type"],
"size": 10 + (wi % 8), "group": entity_type["type"],
})
graph_edges.append({
"source": doc_node_id, "target": node_id,
"label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)),
})
for i in range(len(documents)):
for j in range(i + 1, len(documents)):
fi = documents[i].filename.lower().rsplit(".", 1)[0]
fj = documents[j].filename.lower().rsplit(".", 1)[0]
words_i = set(fi.replace("_", " ").replace("-", " ").split())
words_j = set(fj.replace("_", " ").replace("-", " ").split())
common = words_i & words_j
if common and len(common) > 0:
graph_edges.append({
"source": f"doc-{documents[i].id[:8]}",
"target": f"doc-{documents[j].id[:8]}",
"label": f"共有关键词: {', '.join(list(common)[:3])}",
"weight": min(0.9, 0.3 * len(common)),
})
graph_data = {
"nodes": graph_nodes,
"edges": graph_edges,
"stats": {
"total_nodes": len(graph_nodes), "total_edges": len(graph_edges),
"document_count": len(documents),
},
}
kb.knowledge_graph_count = len(graph_nodes)
kb.updated_at = cst_now()
return graph_data
+240
View File
@@ -0,0 +1,240 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
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
def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list:
"""
解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。
返回: [{id, name, description, transport, endpoint, url, headers}]
"""
if not mcp_ids:
return []
servers = db.query(MCPServer).filter(
MCPServer.id.in_(mcp_ids),
MCPServer.is_enabled == True,
).all()
out = []
for s in servers:
try:
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
except Exception:
headers = {}
out.append({
"id": s.id,
"name": s.name,
"description": s.description or "",
"transport": s.transport_type or "streamable_http",
"endpoint": s.url or "",
"url": s.url or "",
"headers": headers,
})
return out
def _list_enabled_mcp_servers(db: Session) -> list:
"""返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。"""
servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all()
out = []
for s in servers:
try:
headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {})
except Exception:
headers = {}
out.append({
"id": s.id,
"name": s.name,
"description": s.description or "",
"transport": s.transport_type or "streamable_http",
"endpoint": s.url or "",
"url": s.url or "",
"headers": headers,
})
return out
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+378
View File
@@ -0,0 +1,378 @@
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
logger = logging.getLogger(__name__)
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
"""Get current time in CST timezone"""
return datetime.now(CST_TZ)
def _html_escape(s: str) -> str:
return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;"))
def _outbound_proxy() -> str:
"""出网代理地址(兜底)。默认经 172.18.0.1:7890 智能路由代理;
运行时若未注入代理环境变量则回退到该地址。"""
import os
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理。
2026-07-15 调整:AGNES(apihub.agnes-ai.com / platform-outputs.agnes-ai.space)
经用户实测走香港代理 103.242.14.208:11495 延迟更低、更稳定,故改为走 HK 代理
(带鉴权),不再直连,也不经智能路由代理(172.18.0.1:7890 对 AGNES 返 502)。
其余国内/平台域名直连,真正的境外域名走 _outbound_proxy()。
注意:httpx 默认 trust_env=True 会忽略 proxy 参数而改用环境变量 HTTP_PROXY,
故调用方必须同时传 trust_env=False 才能真正走此处指定的代理。
"""
import os
from urllib.parse import urlparse
host = (urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
# 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。
if "qihuyun.com" in host:
return None
# 尊重 NO_PROXY:国内域名(硅基流动/DeepSeek/小米MoE 等)直连更稳定,
# 避免经易波动的智能路由代理 172.18.0.1:7890(对国内域名偶发连接重置)。
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
if host == _np or host.endswith("." + _np) or _np in host:
return None
return _outbound_proxy()
def _extract_text_from_json(obj) -> str:
"""从 n8n / 通用 webhook 的任意 JSON 响应中尽力抽取文本回答。"""
if obj is None:
return ""
if isinstance(obj, str):
return obj
if isinstance(obj, (int, float, bool)):
return str(obj)
if isinstance(obj, list):
# 取第一个可抽取到文本的元素
for it in obj:
t = _extract_text_from_json(it)
if t:
return t
return ""
if isinstance(obj, dict):
# 常见回答字段优先级
for k in ("output", "text", "answer", "response", "reply",
"message", "content", "result", "data"):
if k in obj:
t = _extract_text_from_json(obj[k])
if t:
return t
# 兜底:整体序列化
try:
return json.dumps(obj, ensure_ascii=False)
except Exception:
return str(obj)
return str(obj)
class CreateConversationRequest(BaseModel):
title: str = Field(default="新对话")
model_id: Optional[str] = None
class UpdateConversationRequest(BaseModel):
title: Optional[str] = None
status: Optional[str] = None # active, archived
pinned: Optional[bool] = None
agent_ids: Optional[list] = None
skill_ids: Optional[list] = None
knowledge_base_ids: Optional[list] = None
class SendMessageRequest(BaseModel):
content: str
model_id: Optional[str] = None
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
+706
View File
@@ -0,0 +1,706 @@
"""
Phase 5: 多平台 IM Bot 接入(企业微信 / 飞书 / QQ / 悟空IM)—— 前台用户模块
im_bot_platform.py
为【平台注册用户】提供绑定企业级 IM 平台(需官方开放平台凭证)的能力:
- 每个用户可独立配置多个平台账号(按 user_id + platform 隔离)
- 配置基于各平台官方开放 API(CorpID/Secret、AppID/Secret、Bot AppID/Secret、悟空IM 地址等)
- 提供统一 webhook 接收端点:平台把消息推到 /{platform}/webhook → 七虎AI 应答 → 回写平台
- 对话历史落库 im_bot_chat_log,并镜像到 AI 聊天对应会话窗(source='im_bot_<platform>')
与 im_bot_user(个人微信 iLink 扫码)的区别:
- 个人微信:免 AppID 扫码,长轮询收消息
- 本模块:企业级平台,需开放平台凭证 + 回调地址(由平台主动 POST 到 webhook)
"""
import json
from datetime import datetime, timedelta
from typing import Optional
import requests
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import SessionLocal
from ..models import User
router = APIRouter()
# ============================================================
# 平台注册表(含配置项 schema / 配色 / 说明)
# ============================================================
PLATFORMS = {
"wecom": {
"name": "企业微信",
"icon": "wecom",
"color": "#2F7D32",
"description": "基于企业微信「应用消息」回调,需 CorpID / 应用 Secret / AgentID 与可信回调地址。",
"doc": "在「企业微信管理后台 → 应用管理 → 自建应用」获取 CorpID、Secret、AgentID;并将本页生成的回调地址填入「接收消息 → 设置 API 接收」。",
"fields": [
{"key": "corpid", "label": "企业 ID (CorpID)", "secret": True, "required": True, "placeholder": "wwxxxxxxxxxxxxxxxx"},
{"key": "corpsecret", "label": "应用 Secret", "secret": True, "required": True, "placeholder": "应用管理中的 Secret"},
{"key": "agentid", "label": "应用 AgentID", "secret": False, "required": True, "placeholder": "1000002"},
],
},
"feishu": {
"name": "飞书",
"icon": "feishu",
"color": "#3370FF",
"description": "基于飞书「事件订阅 + 消息卡片」,需 App ID / App Secret 与请求地址。",
"doc": "在「飞书开放平台 → 凭证与基础信息」获取 App ID / App Secret;在「事件订阅」中把本页生成的请求地址填入,并订阅「接收消息 v2.0」事件。",
"fields": [
{"key": "app_id", "label": "App ID", "secret": True, "required": True, "placeholder": "cli_xxxxxxxxxxxxxxxx"},
{"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "应用 App Secret"},
],
},
"qq": {
"name": "QQ",
"icon": "qq",
"color": "#12B7F5",
"description": "基于 QQ 机器人开放平台(频道/私聊消息),需 Bot AppID / Token 与回调/网关。",
"doc": "在「QQ 开放平台 → 机器人管理」获取 Bot AppID / Bot Token;将本页生成的回调地址配置到「消息回调 URL」。",
"fields": [
{"key": "bot_appid", "label": "Bot AppID", "secret": True, "required": True, "placeholder": "102000000"},
{"key": "bot_token", "label": "Bot Token", "secret": True, "required": True, "placeholder": "机器人 Token"},
],
},
"wukong": {
"name": "悟空IM",
"icon": "wukong",
"color": "#FF6B35",
"description": "基于悟空 IM 开源服务(REST + WebSocket),需服务地址与 App Key / Secret。",
"doc": "部署悟空 IM 后,填入服务 Base URL 与 App Key / Secret;将本页生成的回调地址配置到悟空 IM 的「消息回调 Webhook」。",
"fields": [
{"key": "base_url", "label": "服务地址 (Base URL)", "secret": False, "required": True, "placeholder": "http://im.yourdomain.com"},
{"key": "app_key", "label": "App Key", "secret": True, "required": True, "placeholder": "悟空 App Key"},
{"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "悟空 App Secret"},
],
},
}
PLATFORM_KEYS = list(PLATFORMS.keys())
# ============================================================
# 通用 DB 工具
# ============================================================
def _execute_raw(db, sql_text, params=None):
result = db.execute(sql_text, params or {})
if result.returns_rows:
return result.fetchall()
db.commit()
return result
def _row_to_dict(row) -> dict:
if row is None:
return None
d = {}
for col in row._mapping.keys():
val = row._mapping[col]
if isinstance(val, datetime):
val = val.isoformat()
d[col] = val
return d
def _ensure_table(db):
"""确保 im_bot_platform_account 表存在(首次调用时创建)。"""
db.execute(text("""
CREATE TABLE IF NOT EXISTS im_bot_platform_account (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(64) DEFAULT NULL,
platform VARCHAR(32) NOT NULL,
status VARCHAR(32) DEFAULT 'disconnected',
creds MEDIUMTEXT NULL,
send_url VARCHAR(512) NULL,
api_base VARCHAR(512) NULL,
bot_token VARCHAR(512) NULL,
webhook_token VARCHAR(64) NULL,
last_error MEDIUMTEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_platform (user_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""))
db.commit()
def _account_row(db, user_id: str, platform: str):
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid AND platform=:pf LIMIT 1"),
{"uid": user_id, "pf": platform},
)
return rows[0] if rows else None
def _mask_creds(creds: dict) -> dict:
"""脱敏凭据,仅在列出配置时返回(隐藏真实值,仅标记是否已填)。"""
if not creds:
return {}
out = {}
for k, v in creds.items():
if v is None or v == "":
out[k] = ""
else:
sval = str(v)
out[k] = ("***" + sval[-4:]) if len(sval) > 4 else "***"
return out
# ============================================================
# 七虎AI 大脑(复用 im_llm 的真实 LLM 调用)
# ============================================================
def _recent_history(user_id: str, platform: str, limit: int = 6) -> str:
db = None
try:
db = SessionLocal()
rows = _execute_raw(db, text(
"SELECT role, content FROM im_bot_chat_log "
"WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim"
), {"uid": user_id, "pf": platform, "lim": limit})
pairs = []
for r in reversed(rows):
dd = _row_to_dict(r)
role = "用户" if dd["role"] == "user" else "七虎AI"
pairs.append(f"{role}:{dd['content']}")
return "\n".join(pairs)
except Exception:
return ""
finally:
if db:
db.close()
def _qihua_reply(text: str, user_id: str = None, platform: str = "im") -> str:
prompt = text
if user_id:
hist = _recent_history(user_id, platform)
if hist:
prompt = (
"以下是你与该用户最近的对话历史,请结合上下文作答:\n"
f"{hist}\n\n"
f"用户当前问题:{text}"
)
db = None
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, platform, user_id=user_id)
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
if db:
db.close()
# ============================================================
# 对话历史落库 + 镜像到 AI 聊天
# ============================================================
def _log_chat(user_id, platform, from_user, context_token, role, content):
db = None
try:
db = SessionLocal()
_execute_raw(db, text(
"INSERT INTO im_bot_chat_log "
"(user_id, platform, from_user, context_token, role, content, created_at) "
"VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)"
), {
"uid": user_id, "platform": platform, "fu": from_user,
"ctx": context_token, "role": role, "content": content,
"created": datetime.now(),
})
except Exception:
pass
finally:
if db:
db.close()
_CONV_TITLE = {
"wecom": "企业微信 Bot",
"feishu": "飞书 Bot",
"qq": "QQ Bot",
"wukong": "悟空IM Bot",
}
def _get_or_create_conv(db: Session, user_id: str, platform: str) -> str:
src = f"im_bot_{platform}"
rows = _execute_raw(db, text(
"SELECT id FROM conversations WHERE user_id=:uid AND source=:src "
"AND status<>'archived' ORDER BY updated_at DESC LIMIT 1"
), {"uid": user_id, "src": src})
if rows:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
now = datetime.now()
_execute_raw(db, text(
"INSERT INTO conversations "
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
"VALUES (:id, :uid, :title, 'active', 0, 0, :src, :c, :u)"
), {"id": cid, "uid": user_id, "title": _CONV_TITLE.get(platform, "IM Bot"), "src": src, "c": now, "u": now})
return cid
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)"
), {
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
"content": content, "c": created or datetime.now(),
})
def _mirror_to_ai_chat(user_id: str, platform: str, user_content: str, assistant_content: str):
if not user_id:
return
db = None
try:
db = SessionLocal()
cid = _get_or_create_conv(db, user_id, platform)
now = datetime.now()
_append_ai_message(db, cid, "user", user_content, created=now)
_append_ai_message(db, cid, "assistant", assistant_content,
created=now + timedelta(milliseconds=50))
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
{"u": datetime.now(), "id": cid})
except Exception:
pass
finally:
if db:
db.close()
# ============================================================
# 平台发送适配器(把 AI 回复回写到各平台)
# ============================================================
def _wecom_send(creds: dict, to_user: str, text: str) -> (bool, str):
corpid = creds.get("corpid")
corpsecret = creds.get("corpsecret")
agentid = creds.get("agentid")
if not (corpid and corpsecret and agentid):
return False, "缺少 CorpID/Secret/AgentID"
try:
token_resp = requests.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": corpid, "corpsecret": corpsecret}, timeout=15,
)
token_resp.raise_for_status()
tj = token_resp.json()
if tj.get("errcode", 0) != 0:
return False, f"获取 access_token 失败: {tj.get('errmsg')}"
access_token = tj["access_token"]
send_resp = requests.post(
"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": access_token},
json={"touser": to_user, "msgtype": "text", "agentid": int(agentid),
"text": {"content": text}},
timeout=15,
)
sj = send_resp.json()
if sj.get("errcode", 0) == 0:
return True, ""
return False, f"发送失败: {sj.get('errmsg')}"
except Exception as e:
return False, f"企业微信发送异常: {e}"
def _feishu_send(creds: dict, to_user: str, text: str) -> (bool, str):
app_id = creds.get("app_id")
app_secret = creds.get("app_secret")
if not (app_id and app_secret):
return False, "缺少 App ID/Secret"
try:
tkn_resp = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret}, timeout=15,
)
tkn_resp.raise_for_status()
tj = tkn_resp.json()
if tj.get("code", 0) != 0:
return False, f"获取 tenant_access_token 失败: {tj.get('msg')}"
token = tj["tenant_access_token"]
send_resp = requests.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
headers={"Authorization": f"Bearer {token}"},
json={"receive_id": to_user, "msg_type": "text", "content": json.dumps({"text": text})},
timeout=15,
)
sj = send_resp.json()
if sj.get("code", 0) == 0:
return True, ""
return False, f"发送失败: {sj.get('msg')}"
except Exception as e:
return False, f"飞书发送异常: {e}"
def _generic_send(creds: dict, send_url: str, to_user: str, text: str) -> (bool, str):
"""QQ / 悟空IM 等:把回复 POST 到平台提供的发送地址(send_url)。"""
if not send_url:
return False, "未配置发送地址(send_url)"
try:
resp = requests.post(
send_url,
json={"to": to_user, "text": text, "creds": creds},
timeout=15,
)
if resp.ok:
return True, ""
return False, f"发送失败 HTTP {resp.status_code}"
except Exception as e:
return False, f"发送异常: {e}"
def _send_reply(platform: str, account: dict, to_user: str, text: str) -> (bool, str):
creds = account.get("creds") or {}
if isinstance(creds, str):
try:
creds = json.loads(creds)
except Exception:
creds = {}
if platform == "wecom":
return _wecom_send(creds, to_user, text)
if platform == "feishu":
return _feishu_send(creds, to_user, text)
# QQ / 悟空IM:通用 send_url 模式
return _generic_send(creds, account.get("send_url"), to_user, text)
def _extract_inbound(platform: str, body: dict):
"""从平台 webhook 请求体中提取 (sender_id, text, context_token)。"""
if platform == "wecom":
# 企业微信回调:Encrypt 报文需解密;此处约定明文回调节点
content = (body.get("Text") or {}).get("Content") or body.get("content") or ""
sender = body.get("FromUserName") or body.get("userid") or ""
ctx = body.get("MsgId") or ""
return sender, content, ctx
if platform == "feishu":
# 飞书事件 v2.0:event.message + event.sender
evt = body.get("event", body)
msg = evt.get("message", {})
sender = evt.get("sender", {}).get("sender_id", {}).get("open_id") or msg.get("chat_id") or ""
content = (msg.get("content") or "")
try:
content = json.loads(content).get("text", "") if content else ""
except Exception:
pass
ctx = msg.get("message_id") or ""
return sender, content, ctx
if platform == "qq":
d = body.get("d", body)
content = d.get("content", "")
sender = d.get("author", {}).get("id") or d.get("user_id") or ""
ctx = d.get("id") or ""
return sender, content, ctx
# wukong / 通用
sender = body.get("from") or body.get("from_user") or body.get("sender") or ""
content = body.get("content") or body.get("text") or body.get("message") or ""
ctx = body.get("msg_id") or body.get("id") or ""
return sender, content, ctx
# ============================================================
# 前台用户接口(按 user_id 隔离)
# ============================================================
@router.get("/platforms")
async def list_supported_platforms(user: User = Depends(get_current_user)):
"""列出全部支持的平台及其配置 schema(供前端渲染表单)。"""
result = []
for key in PLATFORM_KEYS:
p = PLATFORMS[key]
result.append({
"key": key, "name": p["name"], "icon": p["icon"], "color": p["color"],
"description": p["description"], "doc": p["doc"], "fields": p["fields"],
})
return {"success": True, "data": result}
@router.get("/list")
async def list_my_platforms(user: User = Depends(get_current_user)):
"""当前用户已配置的各平台状态。"""
db = SessionLocal()
try:
_ensure_table(db)
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid ORDER BY platform"),
{"uid": user.id},
)
items = []
for r in rows:
d = _row_to_dict(r)
items.append({
"id": d["id"], "platform": d["platform"], "status": d["status"],
"configured": True,
"send_url": d.get("send_url"),
"last_error": d.get("last_error"),
"updated_at": d.get("updated_at"),
})
# 补全未配置平台为占位
configured = {it["platform"] for it in items}
for key in PLATFORM_KEYS:
if key not in configured:
items.append({"id": None, "platform": key, "status": "disconnected",
"configured": False, "send_url": None, "last_error": None, "updated_at": None})
return {"success": True, "data": items}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/config")
async def get_platform_config(platform: str, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if not row:
return {"success": True, "data": {"configured": False, "status": "disconnected"}}
d = _row_to_dict(row)
creds = d.get("creds")
if isinstance(creds, str):
try:
creds = json.loads(creds)
except Exception:
creds = {}
return {"success": True, "data": {
"configured": True,
"status": d.get("status"),
"creds": _mask_creds(creds or {}),
"send_url": d.get("send_url"),
"last_error": d.get("last_error"),
"webhook_token": d.get("webhook_token"),
"updated_at": d.get("updated_at"),
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/{platform}/config")
async def save_platform_config(platform: str, body: dict, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
schema = PLATFORMS[platform]
required = [f["key"] for f in schema["fields"] if f.get("required")]
creds = {}
for f in schema["fields"]:
k = f["key"]
v = body.get(k)
if f.get("required") and not v:
raise HTTPException(status_code=400, detail=f"缺少必填项:{f['label']}")
creds[k] = v
send_url = body.get("send_url")
api_base = body.get("api_base")
import secrets as _sec
webhook_token = _sec.token_hex(16)
row = _account_row(db, user.id, platform)
if row:
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET creds=:creds, send_url=:su, api_base=:ab, "
"status='connected', last_error=NULL, webhook_token=:wt, updated_at=:u WHERE id=:id"
), {"creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base,
"wt": webhook_token, "u": datetime.now(), "id": _row_to_dict(row)["id"]})
else:
_execute_raw(db, text(
"INSERT INTO im_bot_platform_account "
"(user_id, platform, status, creds, send_url, api_base, webhook_token, created_at, updated_at) "
"VALUES (:uid, :pf, 'connected', :creds, :su, :ab, :wt, :c, :u)"
), {"uid": user.id, "pf": platform,
"creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base,
"wt": webhook_token, "c": datetime.now(), "u": datetime.now()})
return {"success": True, "message": f"{schema['name']} 配置已保存并启用"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/{platform}/disconnect")
async def disconnect_platform(platform: str, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if row:
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET status='disconnected', creds=NULL, "
"bot_token=NULL, send_url=NULL, webhook_token=NULL, updated_at=:u WHERE id=:id"
), {"u": datetime.now(), "id": _row_to_dict(row)["id"]})
return {"success": True, "message": f"已断开 {PLATFORMS[platform]['name']} 连接"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/history")
async def get_platform_history(
platform: str,
user: User = Depends(get_current_user),
limit: int = Query(200),
):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
rows = _execute_raw(
db,
text("SELECT id, role, content, platform, from_user, created_at "
"FROM im_bot_chat_log WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim"),
{"uid": user.id, "pf": platform, "lim": min(int(limit), 500)},
)
items = [_row_to_dict(r) for r in rows]
items.reverse()
return {"success": True, "data": {"messages": items, "total": len(items)}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/webhook-url")
async def get_webhook_url(platform: str, user: User = Depends(get_current_user), request: Request = None):
"""返回本账号在该平台的回调/webhook 地址(配置到对应开放平台用)。"""
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if not row:
raise HTTPException(status_code=400, detail="请先保存配置再获取回调地址")
d = _row_to_dict(row)
wt = d.get("webhook_token")
base = str(request.base_url).rstrip("/") if request else "http://localhost:12888"
url = f"{base}/app/api/im/platform/{platform}/webhook?token={wt}"
return {"success": True, "data": {"webhook_url": url}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
# ============================================================
# 平台回调 webhook(由各 IM 平台主动 POST,无需用户登录态)
# ============================================================
@router.post("/{platform}/webhook")
async def platform_webhook(platform: str, request: Request, token: str = Query(None)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
try:
body = await request.json()
except Exception:
try:
form = await request.form()
body = dict(form)
except Exception:
body = {}
db = SessionLocal()
try:
_ensure_table(db)
row = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE platform=:pf AND webhook_token=:wt AND status='connected' LIMIT 1"),
{"pf": platform, "wt": token},
)
if not row:
return {"success": False, "error": "账号未找到或未启用"}
acc = _row_to_dict(row[0])
user_id = acc.get("user_id")
sender, content, ctx = _extract_inbound(platform, body)
if not content:
return {"success": True, "echo": "no content"}
reply = _qihua_reply(content, user_id, platform)
ok, err = _send_reply(platform, acc, sender, reply)
if ok:
if user_id:
_log_chat(user_id, platform, sender, ctx, "user", content)
_log_chat(user_id, platform, sender, ctx, "assistant", reply)
_mirror_to_ai_chat(user_id, platform, content, reply)
return {"success": True, "replied": True}
else:
# 记录发送失败,便于排查
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET last_error=:e, updated_at=:u WHERE id=:id"
), {"e": err[:500], "u": datetime.now(), "id": acc["id"]})
return {"success": False, "error": err}
except Exception as e:
return {"success": False, "error": str(e)}
finally:
db.close()
# ============================================================
# 应用服务(前台浏览已发布的 AI 应用)
# ============================================================
@router.get("/apps")
async def list_public_apps(
user: User = Depends(get_current_user),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=48),
):
"""浏览平台已发布、对会员可见的 AI 应用服务。"""
db = SessionLocal()
try:
where = "WHERE status='published' AND is_template=0 AND is_public=1"
params: dict = {}
if category:
where += " AND category=:cat"
params["cat"] = category
if search:
where += " AND (name LIKE :s OR description LIKE :s2)"
params["s"] = f"%{search}%"
params["s2"] = f"%{search}%"
total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0
offset = (page - 1) * page_size
rows = db.execute(text(
f"SELECT id, name, description, type, category, icon, tags, published_at "
f"FROM ai_applications {where} ORDER BY published_at DESC LIMIT :lim OFFSET :off"
), {**params, "lim": page_size, "off": offset}).fetchall()
type_names = {
"chatbot": "对话机器人", "assistant": "智能助理",
"workflow": "工作流", "api_service": "API 服务",
}
cat_names = {
"customer_service": "客服助手", "data_analysis": "数据分析", "coding": "编程辅助",
"writing": "内容创作", "education": "教育培训", "marketing": "营销推广",
"hr": "人力资源", "finance": "财务法务", "custom": "自定义",
}
items = []
for r in rows:
items.append({
"id": r[0], "name": r[1], "description": r[2], "type": r[3],
"type_name": type_names.get(r[3], r[3]), "category": r[4],
"category_name": cat_names.get(r[4], r[4]), "icon": r[5],
"tags": r[6], "published_at": r[7].isoformat() if r[7] else None,
})
return {"success": True, "data": {
"items": items, "total": total, "page": page, "page_size": page_size,
"total_pages": (total + page_size - 1) // page_size,
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
File diff suppressed because it is too large. Load diff
+234
View File
@@ -0,0 +1,234 @@
"""
IM Bot 真实 LLM 调用。
原实现位于 channels.py(通用频道管理模块),该模块已移除。
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
行为(对齐 AI 聊天):
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
不再对"制作图片"这类请求只回文本。
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
"""
import asyncio
import json
import logging
import re as _re
from datetime import datetime, timezone, timedelta
from typing import Optional
from urllib.parse import urlparse as _urlparse
import httpx
from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
from app.core.observability import get_trace_id
logger = logging.getLogger(__name__)
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
def _proxy_for_url(url: str) -> str | None:
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
国内域名(尊重 NO_PROXY)直连,更稳定。
"""
import os
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
if "qihuyun.com" in host:
return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
if host == _np or host.endswith("." + _np) or _np in host:
return None
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if timeout is not None:
async def _timed():
return await asyncio.wait_for(coro, timeout=timeout)
target = _timed()
else:
target = coro
if loop is not None and loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(1) as ex:
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
return asyncio.run(target)
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里“查无此项”。
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
"""
if not user_id or not ai_model:
return
try:
base = 1200 if media_type == "video" else 400
est = base + (len(prompt or "") // 4)
db.add(TokenUsageLog(
id=gen_uuid(),
user_id=user_id,
model_id=ai_model.id,
model_name=ai_model.name,
trace_id=get_trace_id(),
prompt_tokens=est,
completion_tokens=0,
total_tokens=est,
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
))
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由与已安装技能自动发现。"""
try:
from app.api.chat import (
_generate_media,
detect_media_intent,
_discover_installed_skills,
_build_system_prompt,
_execute_skill_tool,
)
# ===== 1) 媒体意图路由(Issue 7)=====
intent = detect_media_intent(prompt)
if intent:
ai_model = resolve_auto_model(intent, db)
if ai_model:
try:
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
except Exception:
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
if result:
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
s = result.strip()
if s.startswith("<img") or s.startswith("<video"):
m = _re.search(r'src="([^"]+)"', s)
url = m.group(1) if m else ""
media_type = "image" if s.startswith("<img") else "video"
if not url:
return "(生成完成,但未能提取媒体地址)"
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
_log_media_usage(db, user_id, ai_model, media_type, prompt)
return {"__media__": True, "media_type": media_type, "url": url}
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
if s.startswith("⚠️") or s.startswith("(媒体"):
return s
return "(媒体生成未能返回有效内容,请重试)"
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
ai_model = resolve_auto_model("text", db)
if not ai_model:
return "(未配置默认文本模型,无法生成回复)"
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
return "(模型未配置 API Key 或地址,无法生成回复)"
# 自动发现本账号已安装且平台已部署的技能
skills = []
if user_id:
try:
skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
skills = []
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[],
kb_context="",
user_content=prompt,
mcp=[],
auto_discovered=bool(skills),
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
max_rounds = 3
ai_content = ""
for _ in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
if function_definitions and skills:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
with httpx.Client(
timeout=ai_model.timeout or 120,
proxy=_proxy_for_url(api_url),
trust_env=False,
) as client:
resp = client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=request_body,
)
if resp.status_code != 200:
return f"(AI 模型调用失败 HTTP {resp.status_code})"
data = resp.json()
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
return ai_content or "(AI 回复为空)"
except Exception as e:
return f"(AI 回复生成失败:{e})"
+81 -22
View File
@@ -851,9 +851,12 @@ def get_documents(
@router.post("/bases/{base_id}/documents")
def upload_document(
async def upload_document(
base_id: str,
req: CreateDocumentRequest,
file: Optional[UploadFile] = File(None),
filename: str = Form(...),
file_type: Optional[str] = Form(None),
file_size: Optional[int] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -864,7 +867,7 @@ def upload_document(
raise HTTPException(status_code=404, detail="知识库不存在")
# 确定文件扩展名和存储路径
file_ext = (req.file_type or "txt").lower()
file_ext = (file_type or "txt").lower()
if file_ext.startswith("."):
file_ext = file_ext[1:]
@@ -877,28 +880,69 @@ def upload_document(
stored_filename = f"{doc_id}.{file_ext}"
stored_path = os.path.join(storage_dir, stored_filename)
# 文本解析:根据文件类型生成模拟文档内容
# 实际生产环境中应使用 PyPDF2/pdfplumber(PDF)、python-docx(Word)、openpyxl(Excel)等库
file_size = req.file_size or len(req.filename.encode('utf-8')) * 100
content = _generate_document_content(req.filename, file_ext, file_size)
# 文件内容解析
actual_size = file_size or 0
content = ""
uploaded_content = b""
# 将内容写入存储文件
try:
with open(stored_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(f"知识库文档已写入: {stored_path}, size={len(content)} chars")
except Exception as e:
logger.error(f"知识库文档写入失败: {stored_path}, error={e}")
raise HTTPException(status_code=500, detail=f"文件存储失败: {str(e)}")
# 如果有真实文件上传,先保存并读取内容
if file and file.filename:
try:
uploaded_content = await file.read()
actual_size = len(uploaded_content)
# 先写入原始文件
with open(stored_path, "wb") as f:
f.write(uploaded_content)
# 对于文本类格式,尝试直接解析
text_exts = {"txt", "md", "csv", "json", "yaml", "yml", "xml", "py", "js", "ts", "html", "css", "log"}
if file_ext in text_exts:
try:
content = uploaded_content.decode("utf-8")
except UnicodeDecodeError:
try:
content = uploaded_content.decode("gbk")
except UnicodeDecodeError:
content = f"[二进制文件: {filename}, 大小: {actual_size} bytes]"
else:
# 非文本格式:记录文件但内容用摘要代替
content = _generate_document_content(filename, file_ext, actual_size)
logger.info(f"非文本文件 {filename}({file_ext}) 已保存到 {stored_path}, 使用模拟内容用于检索")
except Exception as e:
logger.error(f"文件读取失败: {e}")
content = _generate_document_content(filename, file_ext, actual_size)
else:
# 无文件上传(兼容旧 JSON 模式):生成模拟内容
actual_size = file_size or len(filename.encode('utf-8')) * 100
content = _generate_document_content(filename, file_ext, actual_size)
# 写入模拟内容
try:
with open(stored_path, "w", encoding="utf-8") as f:
f.write(content)
except Exception as e:
logger.error(f"知识库文档写入失败: {stored_path}, error={e}")
raise HTTPException(status_code=500, detail=f"文件存储失败: {str(e)}")
# 文本分块:将内容按段落分割为chunks
# 确保文件已写入(防止重复写入)
if uploaded_content and file_ext not in {"txt", "md", "csv", "json", "yaml", "yml", "xml", "py", "js", "ts", "html", "css", "log"}:
pass # 已在上面 wb 写入
elif not os.path.exists(stored_path):
try:
with open(stored_path, "w", encoding="utf-8") as f:
f.write(content)
except Exception as e:
logger.error(f"知识库文档写入失败: {stored_path}, error={e}")
raise HTTPException(status_code=500, detail=f"文件存储失败: {str(e)}")
logger.info(f"知识库文档已处理: {stored_path}, ext={file_ext}, size={actual_size}, content_len={len(content)}")
# 文本分块
chunks = _split_content_into_chunks(content, chunk_size=500)
doc = KnowledgeDocument(
id=doc_id,
knowledge_base_id=base_id,
filename=req.filename,
file_size=file_size,
filename=filename,
file_size=actual_size,
file_type=file_ext,
status="completed",
chunk_count=len(chunks),
@@ -911,8 +955,8 @@ def upload_document(
kb.document_count = (kb.document_count or 0) + 1
kb.updated_at = datetime.now()
# 同步创建Asset记录(问题9:资产关联知识库文档用量)
_sync_asset_for_knowledge_doc(db, current_user.id, doc_id, req.filename, file_ext, file_size)
# 同步创建Asset记录
_sync_asset_for_knowledge_doc(db, current_user.id, doc_id, filename, file_ext, actual_size)
db.commit()
db.refresh(doc)
@@ -1105,15 +1149,21 @@ def _split_content_into_chunks(content: str, chunk_size: int = 500) -> list:
def _sync_asset_for_knowledge_doc(db: Session, user_id: str, doc_id: str, filename: str, file_type: str, file_size: int):
"""将知识库文档同步为资产记录(问题9:资产关联知识库文档用量)"""
try:
# 文件类型归一化为前端子分类标签(docx/doc->word 等),确保出现在「文件 > Word」子栏
_ft_label = {
"pdf": "pdf", "docx": "word", "doc": "word", "xlsx": "excel", "xls": "excel",
"pptx": "ppt", "ppt": "ppt", "csv": "txt", "md": "md", "txt": "txt",
}.get((file_type or "").lower(), "other")
asset = Asset(
id=gen_uuid(),
user_id=user_id,
name=filename,
category="doc",
category="file",
description=f"知识库文档 (ID: {doc_id})",
file_type=file_type,
file_type=_ft_label,
size=file_size,
status="active",
knowledge_doc_id=doc_id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
@@ -1147,6 +1197,15 @@ def delete_document(
pass # total_size removed
kb.updated_at = datetime.now()
# 同步删除知识库文档对应的资产记录(双向同步)
try:
linked_asset = db.query(Asset).filter(Asset.knowledge_doc_id == doc_id).first()
if linked_asset:
linked_asset.status = "deleted"
linked_asset.updated_at = datetime.now()
except Exception as _e:
logger.warning(f"同步删除关联资产失败: {_e}")
db.delete(doc)
db.commit()
+430 -8
View File
@@ -1,3 +1,4 @@
from app.core.credential import encrypt_secret
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
@@ -5,18 +6,354 @@ from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
import httpx
from urllib.parse import urlparse, urljoin
import re
import json
import os
from ..database import get_db
from ..models import (
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
gen_uuid
AdBanner, MCPServer, ModelProvider, gen_uuid
)
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model as _resolve_auto_model
from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config
router = APIRouter()
def _extract_url_from_text(text: Optional[str]) -> Optional[str]:
"""Extract first http/https URL from text."""
if not text:
return None
match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text)
return match.group(0) if match else None
def _skill_http_proxy():
"""容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。"""
return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None
def _sanitize_skill_content(text: str) -> str:
"""仅当内容疑似有效 SKILL.md 时才接受,避免把无关网页/乱码写进 skill_content。"""
if not text:
return ""
t = text.strip()
if len(t) < 50:
return ""
low = t.lower()
# 拒绝整页 HTML(避免把网页源码当技能内容写库)
if low.startswith("<!doctype") or low.startswith("<html") or "<script" in low or t.count("<div") > 3:
return ""
# 接受:YAML front matter 或 含 markdown 标题(# / ##)
has_front = t.startswith("---") and ("name:" in t or "description:" in t)
has_heading = t.startswith("#") or "\n#" in t or "##" in t
if not (has_front or has_heading):
return ""
return t
def _extract_skill_from_html(html: str, base_url: str) -> str:
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
proxy = _skill_http_proxy()
# 1) fenced code block
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
for f in fences:
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
return f.strip()
# 2) <pre>/<code> blocks
for tag in (r"pre", r"code"):
for b in re.findall(r"<%s[^>]*>(.*?)</%s>" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
txt = re.sub(r"<[^>]+>", "", b)
if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
return txt.strip()
# 3) 跳转到 raw SKILL.md 链接(最多试 3 个)
links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
for link in links[:3]:
try:
abs_url = urljoin(base_url, link)
with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
rr = c.get(abs_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if rr.status_code == 200 and len(rr.text) > 80:
return rr.text.strip()
except Exception:
continue
return ""
def _fetch_skill_content(url: str) -> str:
"""抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
if not url or not url.startswith("http"):
return ""
proxy = _skill_http_proxy()
try:
with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if r.status_code != 200:
return ""
raw = r.text
ctype = (r.headers.get("content-type") or "").lower()
u = url.rstrip("/")
if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
return _sanitize_skill_content(raw)
return _sanitize_skill_content(_extract_skill_from_html(raw, url))
except Exception:
return ""
# ===== Agent 接入前连通性 / 鉴权校验 =====
def _normalize_dify_url(endpoint: str) -> str:
ep = endpoint.rstrip("/")
if ep.endswith("/chat-messages"):
return ep
if ep.endswith("/v1/chat-messages"):
return ep
if ep.endswith("/v1"):
return ep + "/chat-messages"
return ep + "/v1/chat-messages"
def _short_err(e: Exception) -> str:
return str(e)[:160]
def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
"""POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。
背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
/v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
容器内 env 有坏代理(502),必须 trust_env=False 直连。
"""
cur = url
last = None
for _ in range(4):
last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
trust_env=False, follow_redirects=False)
if last.status_code in (301, 302, 307, 308):
loc = last.headers.get("location")
if not loc:
return last
cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
continue
return last
return last
def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
"""
接入 Agent 前的连通性 + 鉴权校验。
返回 {ok, verified, detail}
ok: 是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
"""
ep = (endpoint or "").strip()
if not ep or not ep.startswith(("http://", "https://")):
return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
platform = (platform or "dify").lower()
# 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
if platform == "dify":
url = _normalize_dify_url(ep)
payload = {
"inputs": {},
"query": "连通性测试",
"user": "__verify__",
"response_mode": "blocking",
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
r = _post_follow(url, headers, payload, timeout=15.0)
except Exception as e:
return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
if r.status_code == 200:
return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
if r.status_code in (401, 403):
return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
if r.status_code == 404:
return {"ok": False, "verified": False,
"detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
if r.status_code in (301, 302, 307, 308):
loc = r.headers.get("location") or ""
return {"ok": False, "verified": False,
"detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
if r.status_code == 400:
# 400 需区分两种情况:
# (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
# (b) 纯参数错误
body = (r.text or "")
low = body.lower()
if ("run failed" in low or "error code" in low or "workflow" in low
or "node" in low or "-500" in body):
return {"ok": True, "verified": True,
"detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
"(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
# n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
try:
r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
if r.status_code < 500:
return {"ok": True, "verified": False,
"detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
except Exception as e:
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
# ===== 业务场景分类(面向用户的技能/MCP 分类,与后端模型路由的 capability_registry 解耦) =====
# 注意:MarketApp.capability 列在此存放「业务场景 id」(见 MARKET_SCENES),不再是 text/vision 等模型能力。
MARKET_SCENES: list = [
# 元宝生成的「岗位 / 职能」分类(面向使用者视角,与后端模型路由能力解耦)
{"id": "design", "label": "🎨 做设计", "color": "#EC4899", "desc": "页面、UI、视觉", "kw": "做设计"},
{"id": "frontend", "label": "💻 写前端", "color": "#3B82F6", "desc": "Web 页面、React、Next.js", "kw": "写前端"},
{"id": "mobile", "label": "📱 做移动端", "color": "#06B6D4", "desc": "React Native、Expo", "kw": "做移动端"},
{"id": "backend", "label": "🧪 后端/全栈", "color": "#8B5CF6", "desc": "API、数据库、TypeScript", "kw": "后端"},
{"id": "qa", "label": "🧪 测试质量", "color": "#6366F1", "desc": "TDD、Playwright、验证", "kw": "测试"},
{"id": "search", "label": "🔍 搜索信息", "color": "#0EA5E9", "desc": "网页抓取、质疑、SEO", "kw": "搜索"},
{"id": "docs", "label": "✍️ 写文档", "color": "#F59E0B", "desc": "Word/PPT/PDF、文案、PRD", "kw": "写文档"},
{"id": "marketing", "label": "📢 推广获客", "color": "#F43F5E", "desc": "营销、社媒、邮件、定价", "kw": "推广"},
{"id": "workflow", "label": "🔄 工作流", "color": "#14B8A6", "desc": "计划、调试、分级、原型", "kw": "工作流"},
{"id": "secops", "label": "🛡️ 安全运维", "color": "#EF4444", "desc": "安全审计、Azure、K8s", "kw": "安全"},
{"id": "learning", "label": "🎓 学习教学", "color": "#10B981", "desc": "教学、提问、架构改进", "kw": "学习"},
{"id": "video", "label": "🎬 视频多媒体", "color": "#D946EF", "desc": "AI视频、数字人", "kw": "视频"},
{"id": "collab", "label": "🏢 办公协作", "color": "#64748B", "desc": "飞书全家桶", "kw": "办公"},
{"id": "ai", "label": "🛠️ AI能力", "color": "#A855F7", "desc": "创建Skill、代码审查、统计", "kw": "AI能力"},
]
_VALID_SCENE_IDS = {s["id"] for s in MARKET_SCENES}
def _scene_label(scene_id):
return next((s["label"] for s in MARKET_SCENES if s["id"] == scene_id), None)
def _classify_scene(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
"""用平台文本模型把应用归类到面向用户的业务场景之一;失败返回 None(不阻断导入)。"""
try:
text_model = _resolve_auto_model("text", db)
if not text_model:
return None
provider = None
if getattr(text_model, "provider_id", None):
provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
api_key = text_model.api_key or (provider.api_key if provider else None)
if not api_key:
return None
api_url = text_model.endpoint or (
f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
)
if not api_url:
return None
options = "、".join(f"{s['id']}({s['label']}:{s.get('desc','')})" for s in MARKET_SCENES)
sys_prompt = (
"你是 AI 应用市场的『技能场景分类器』。请站在使用者视角,根据应用名称、描述与内容,"
"判断它最贴合下列哪一类【岗位 / 职能 / 业务场景】,并只输出对应英文 id:"
f"{options}。\n必须只返回如下 JSON,且 capability 的值只能是上面列出的英文 id(小写),"
"不要返回中文标签、不要任何解释或 Markdown 代码块:{\"capability\": \"<id>\"}"
)
user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_text},
]
proxy = _skill_http_proxy()
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
r = c.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
)
if r.status_code != 200:
return None
data = r.json()
text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
# 主解析:标准 JSON 的 capability 字段
m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
if m and m.group(1) in _VALID_SCENE_IDS:
return m.group(1)
# 兜底:在输出中扫描任一合法 id 或中文关键词(容错标签/未加引号/多余文本等情况)
for s in MARKET_SCENES:
sid = s["id"]
if re.search(r'(?<![a-z])' + re.escape(sid) + r'(?![a-z])', text_out):
return sid
kw = s.get("kw", "")
if kw and kw in text_out:
return sid
return None
except Exception:
return None
class VerifyAgentRequest(BaseModel):
platform: Optional[str] = None
endpoint: str
api_key: str
@router.post("/apps/verify-agent")
def verify_agent(
req: VerifyAgentRequest,
current_user: User = Depends(get_current_user),
):
"""接入 Agent 前手动测试连通性 / 鉴权,供前台「测试连接」按钮调用。"""
result = verify_agent_connectivity(req.platform or "dify", req.endpoint, req.api_key)
return {"code": 0, "data": result, "message": "ok"}
class DetectCapabilityRequest(BaseModel):
name: str
description: Optional[str] = None
skill_content: Optional[str] = None
type: Optional[str] = None
@router.post("/apps/detect-capability")
def detect_capability(
req: DetectCapabilityRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""导入前 AI 验证阶段:根据名称/描述/内容识别业务场景分类,供前台预填并允许用户修改。"""
cap = _classify_scene(req.name, req.description, req.skill_content, db)
label = _scene_label(cap)
return {"code": 0, "data": {"capability": cap, "label": label}, "message": "ok"}
@router.get("/ads")
def list_public_ads(db: Session = Depends(get_db)):
"""
前台首页「精选推荐」广告位:返回已上架(active)广告,按排序升序,无需登录。
供 LandingPage 轮播调用,无鉴权依赖。
"""
ads = (
db.query(AdBanner)
.filter(AdBanner.status == "active")
.order_by(AdBanner.sort_order.asc())
.all()
)
return [
{
"id": a.id,
"title": a.title,
"image_url": a.image_url,
"link_app_id": a.link_app_id,
"link_app_name": a.link_app_name,
"sort_order": a.sort_order,
"click_count": a.click_count,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in ads
]
class UploadAppRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = None
@@ -25,11 +362,15 @@ class UploadAppRequest(BaseModel):
source: Optional[str] = None
price: float = Field(default=0, ge=0)
download_url: Optional[str] = None
skill_content: Optional[str] = None
category: Optional[str] = None
capability: Optional[str] = None
tags: Optional[str] = None
is_member_free: Optional[bool] = False
join_promotion: Optional[bool] = False
agent_endpoint: Optional[str] = None
api_key: Optional[str] = None
platform: Optional[str] = None
class PurchaseAppRequest(BaseModel):
@@ -45,7 +386,7 @@ class ReviewAppRequest(BaseModel):
def list_apps(
type: Optional[str] = Query(None),
source: Optional[str] = Query(None),
category: Optional[str] = Query(None),
capability: Optional[str] = Query(None),
search: Optional[str] = Query(None),
sort: str = Query(default="latest"),
page: int = Query(default=1, ge=1),
@@ -59,8 +400,8 @@ def list_apps(
query = query.filter(MarketApp.type == type)
if source:
query = query.filter(MarketApp.source == source)
if category:
query = query.filter(MarketApp.type == category)
if capability:
query = query.filter(MarketApp.capability == capability)
if search:
query = query.filter(
and_(
@@ -91,6 +432,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,
@@ -108,7 +451,8 @@ 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,
"capability": app.capability or None,
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
}
result.append(item)
@@ -162,8 +506,9 @@ def get_app(
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"publisher_id": app.publisher_id,
"download_url": app.download_url,
"detail": app.detail,
"download_url": getattr(app, "download_url", None),
"detail": getattr(app, "detail", None),
"capability": app.capability or None,
"created_at": str(app.created_at),
"purchased": purchased,
"installed": installed,
@@ -178,13 +523,32 @@ def upload_app(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 接入 Agent 时先做连通性 / 鉴权校验,避免把无效连接存进库
if (req.type or "") == "agent" and req.agent_endpoint and req.api_key:
v = verify_agent_connectivity(req.platform or "dify", req.agent_endpoint, req.api_key)
if not v["ok"]:
raise HTTPException(
status_code=400,
detail=f"Agent 连接校验未通过:{v['detail']}",
)
# source 判定:管理员(admin 角色)通过前台市场导入/开发的 Agent/Skill/MCP
# 统一标记为官方(official),其余用户按请求或默认为 user。
if current_user.role == "admin":
app_source = "official"
elif req.source and req.source in ("official", "user"):
app_source = req.source
else:
app_source = "user"
app = MarketApp(
id=gen_uuid(),
name=req.name,
description=req.description,
icon=req.icon,
type=req.type if req.type in ("skill","agent","mcp") else "skill",
source=req.source if req.source in ("official","user") else "user",
source=app_source,
platform=req.platform,
price=req.price,
install_count=0,
rating_avg=0,
@@ -194,14 +558,70 @@ 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=encrypt_secret(req.api_key),
status="published", # 用户创建的应用直接发布
created_at=datetime.now(),
updated_at=datetime.now(),
)
# 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入)
_classify_content = req.skill_content or req.download_url or ""
app.capability = req.capability or _classify_scene(req.name, req.description, _classify_content, db) or None
db.add(app)
db.commit()
db.refresh(app)
# MCP 应用同步注册到后台 MCP Client 管理(mcp_servers 表)
if app.type == "mcp":
code = req.download_url or ""
if code:
app.skill_content = code
parsed = parse_mcp_config(code)
transport = parsed["transport_type"]
app.mcp_transport = transport
db.commit()
db.refresh(app)
existing_server = db.query(MCPServer).filter(MCPServer.id == app.id).first()
if not existing_server:
url = parsed["url"] or _extract_url_from_text(req.description or "") or ""
headers_json = json.dumps(parsed["headers"], ensure_ascii=False) if parsed["headers"] else "{}"
server = MCPServer(
id=app.id,
name=app.name,
description=app.description or "",
transport_type=transport,
command="",
args="{}",
env_vars="{}",
url=url,
headers=headers_json,
is_enabled=True,
status="disconnected",
tools_count=0,
resources_count=0,
created_at=app.created_at or datetime.now(),
updated_at=app.updated_at or datetime.now(),
)
db.add(server)
db.commit()
# 导入后自动做一次连通性测试,状态会同步为 connected / error
_test_mcp_server(server, db)
# Skill / Agent:保存真实技能定义内容,否则对话里读不到 skill_content → 导入成功但失效
if app.type == "skill":
content = req.skill_content or ""
if not content and req.download_url:
# Hub 导入:按目录 URL 抓取真实 SKILL.md 内容回填
content = _fetch_skill_content(req.download_url) or ""
if content:
app.skill_content = content
db.commit()
elif app.type == "agent":
if req.skill_content:
app.skill_content = req.skill_content
db.commit()
# 用户创建/导入的应用自动标记为已安装
existing_install = db.query(AppInstallation).filter(
AppInstallation.user_id == current_user.id,
@@ -226,6 +646,7 @@ def upload_app(
"code": 0,
"data": {
"id": app.id, "name": app.name, "status": app.status,
"capability": app.capability,
"created_at": str(app.created_at),
},
"message": "提交成功,等待审核",
@@ -596,3 +1017,4 @@ def get_agent_resources(
},
"message": "ok",
}
+11 -1
View File
@@ -9,10 +9,11 @@ from datetime import datetime, date
from ..database import get_db
from ..models import (
User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp,
gen_uuid
SmsConfig, gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password
from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter()
@@ -145,6 +146,15 @@ def update_password(
db_user.password_hash = hash_password(req.new_password)
db_user.updated_at = datetime.now()
# 密码修改成功后发送短信通知(若已配置短信且用户绑定手机号)
try:
sms_cfg = db.query(SmsConfig).first()
if sms_cfg and sms_cfg.provider and db_user.phone:
send_sms_text(sms_cfg, db_user.phone, "您的登录密码已修改,如非本人操作请尽快联系客服。")
except Exception as sms_err:
import logging
logging.getLogger("aiflow").warning(f"密码修改短信通知失败(不影响修改结果): {sms_err}")
LogService.log_operation(
user_id=db_user.id, username=db_user.username, action="change_password",
target="user", target_id=db_user.id,
+35 -3
View File
@@ -8,11 +8,12 @@ from datetime import datetime, date, timedelta
from ..database import get_db
from ..models import (
User, TokenUsageLog, TokenBalance, AIModel,
User, TokenUsageLog, TokenBalance, AIModel, SmsConfig,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter()
@@ -83,9 +84,20 @@ def get_rankings(
models_list = []
for r in model_rankings:
model = db.query(AIModel).filter(AIModel.id == r.model_id).first()
if model:
model_name = model.name
else:
# 模型已被删除,从最近一条日志中取 model_name 作为回退
recent = db.query(TokenUsageLog.model_name).filter(
TokenUsageLog.model_id == r.model_id,
TokenUsageLog.model_name.isnot(None),
).first()
model_name = (recent.model_name if recent else None) or (
"Auto 智能匹配" if r.model_id == "auto" else "未知模型"
)
models_list.append({
"model_id": r.model_id,
"model_name": model.name if model else "unknown",
"model_name": model_name,
"total_tokens": int(r.total),
"usage_count": int(r.count),
})
@@ -161,10 +173,18 @@ def get_token_logs(
result = []
for log_item in logs:
model = db.query(AIModel).filter(AIModel.id == log_item.model_id).first()
if model:
model_name = model.name
elif log_item.model_name:
model_name = log_item.model_name # 日志自带的名称
elif log_item.model_id == "auto":
model_name = "🤖 Auto 智能匹配"
else:
model_name = "未知模型 (ID: {})".format(log_item.model_id[:8] if log_item.model_id else "?")
result.append({
"id": log_item.id,
"model_id": log_item.model_id,
"model_name": model.name if model else "unknown",
"model_name": model_name,
"prompt_tokens": log_item.prompt_tokens or 0,
"completion_tokens": log_item.completion_tokens or 0,
"total_tokens": log_item.total_tokens or 0,
@@ -237,6 +257,18 @@ def recharge(
db.commit()
# 充值成功后发送短信通知(若已配置短信且用户绑定手机号)
try:
sms_cfg = db.query(SmsConfig).first()
if sms_cfg and sms_cfg.provider and current_user.phone:
send_sms_text(
sms_cfg, current_user.phone,
f"您已成功充值 {req.amount} tokens,当前余额 {balance.balance} tokens。",
)
except Exception as sms_err:
import logging
logging.getLogger("aiflow").warning(f"充值短信通知失败(不影响充值结果): {sms_err}")
return {
"code": 0,
"data": {
+14 -1
View File
@@ -77,7 +77,8 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
根据能力解析平台默认模型:
1. 优先使用后台"按能力默认模型"中配置的值
2. 文本类能力若未单独配置,回退到全局默认模型(is_default)
3. 其余能力(embedding/rerank/asr 等)若未配置则返回 None
3. 媒体类能力(image/video/audio)若未配置,自动发现第一个启用且具备该能力的模型
4. 其余能力(embedding/rerank/asr 等)若未配置则返回 None
"""
data = get_capability_defaults(db)
mid = data.get(capability)
@@ -94,4 +95,16 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
).first()
if m:
return m
# 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型
# 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频"
if capability in ("image", "video", "audio"):
from sqlalchemy import text as _text
m = db.query(AIModel).filter(
AIModel.is_enabled == True,
AIModel.capabilities.op("JSON_CONTAINS")(
AIModel.capabilities, f'"{capability}"'
),
).first()
if m:
return m
return None
+11 -4
View File
@@ -5,6 +5,7 @@ import os
import logging
from pathlib import Path
from logging.handlers import TimedRotatingFileHandler
from app.core.observability import TraceIdFilter, JsonFormatter
from dotenv import load_dotenv
# 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量)
@@ -80,10 +81,14 @@ SLOW_REQUEST_THRESHOLD = float(os.getenv("SLOW_REQUEST_THRESHOLD", "3.0"))
def setup_file_logging():
"""配置应用日志按天轮转输出到文件"""
log_format = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
use_json = os.getenv("AIFLOW_JSON_LOGS", "0") == "1"
if use_json:
log_format = JsonFormatter()
else:
log_format = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s [tid:%(trace_id)s] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 应用日志 - 按天轮转,保留30天
app_handler = TimedRotatingFileHandler(
@@ -123,10 +128,12 @@ def setup_file_logging():
root_logger.setLevel(logging.INFO)
root_logger.addHandler(app_handler)
root_logger.addHandler(error_handler)
root_logger.addFilter(TraceIdFilter())
# 配置访问日志专用logger
access_logger = logging.getLogger("aiflow.access")
access_logger.addHandler(access_handler)
access_logger.propagate = False
access_logger.addFilter(TraceIdFilter())
return access_logger
+1
View File
@@ -0,0 +1 @@
# app/core package - 统一可观测基座 (Phase 2.1)
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""Phase 3.2 - Agent 凭据加解密 (Fernet 对称加密)。
主密钥获取策略 (防竞态):
- 优先环境变量 AGENT_MASTER_KEY。
- 否则读写 /app/secrets/agent_master_key:
* 部署阶段由 patch 显式生成并落盘 (原子写), 容器重启/提交后稳定存在。
* 运行时只读, 文件存在即读取, 绝不重新生成 —— 避免多进程/多请求并发
重建密钥导致新旧密文无法互解。
* 仅当文件确实不存在时, 才在文件锁保护下生成 (双重检查, 原子替换)。
- 存储格式: 密文以 "enc:" 前缀标识; 非 "enc:" 视为旧明文 (向后兼容, 解密原样返回)。
"""
import os
import logging
import fcntl
from cryptography.fernet import Fernet
logger = logging.getLogger(__name__)
_MASTER_KEY_FILE = "/app/secrets/agent_master_key"
_fernet_instance = None
def _generate_and_persist_key() -> bytes:
key = Fernet.generate_key()
os.makedirs(os.path.dirname(_MASTER_KEY_FILE), exist_ok=True)
tmp = _MASTER_KEY_FILE + ".tmp"
with open(tmp, "w") as f:
f.write(key.decode())
f.flush()
os.fsync(f.fileno())
try:
os.chmod(tmp, 0o600)
except Exception:
pass
os.replace(tmp, _MASTER_KEY_FILE) # 原子替换, 避免读到半截文件
return key
def get_master_key() -> bytes:
env_key = os.environ.get("AGENT_MASTER_KEY")
if env_key:
return env_key.encode() if isinstance(env_key, str) else env_key
# 运行时只读: 文件已存在则直接读取 (部署阶段已生成)
if os.path.exists(_MASTER_KEY_FILE):
try:
with open(_MASTER_KEY_FILE, "r") as f:
k = f.read().strip()
if k:
return k.encode()
except Exception as e:
logger.warning("[credential] 读取主密钥失败: %s" % e)
# 文件缺失: 加锁串行生成, 防多进程并发重建
lockf = open(_MASTER_KEY_FILE + ".lock", "w")
try:
fcntl.flock(lockf, fcntl.LOCK_EX)
if os.path.exists(_MASTER_KEY_FILE): # 双重检查
with open(_MASTER_KEY_FILE, "r") as f:
k = f.read().strip()
if k:
return k.encode()
return _generate_and_persist_key()
finally:
fcntl.flock(lockf, fcntl.LOCK_UN)
try:
lockf.close()
except Exception:
pass
def _fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
_fernet_instance = Fernet(get_master_key())
return _fernet_instance
def encrypt_secret(plain):
if plain is None or plain == "":
return None
if isinstance(plain, str) and plain.startswith("enc:"):
return plain # 已是密文, 幂等
try:
return "enc:" + _fernet().encrypt(plain.encode()).decode()
except Exception as e:
logger.error("[credential] 加密失败: %s" % e)
return None
def decrypt_secret(cipher):
if cipher is None or cipher == "":
return None
if isinstance(cipher, str) and cipher.startswith("enc:"):
try:
return _fernet().decrypt(cipher[4:].encode()).decode()
except Exception as e:
logger.error("[credential] 解密失败(密钥不匹配或数据损坏): %s" % e)
return None
return cipher # 旧明文, 向后兼容
def mask_secret(plain):
if not plain:
return ""
return ("*" * 8) + plain[-4:]
+174
View File
@@ -0,0 +1,174 @@
"""
Qihu-AIFlow 统一可观测基座 (Phase 2.1)
=====================================
提供贯穿一次请求的全局链路 ID(trace_id)与结构化日志能力,
为后续重构(上下文引擎、媒体/LLM 成本归因)建立回归基线。
设计原则(Strangler Fig 增量):
- 纯增量埋点,不改任何既有接口的返回结构。
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
TraceIdFilter 自动注入,调用方无需改动。
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
使用方式:
from app.core.observability import set_trace_id, get_trace_id, span
# 在中间件里为每个请求注入:
set_trace_id(request_id)
# 在关键调用处打计时 span(自动带 trace_id):
async with span("chat.send_message", conv_id=conv_id):
return await _send_message_core(...)
"""
import logging
import time
import uuid
import json
from contextvars import ContextVar
# ---------------------------------------------------------------------------
# trace_id:贯穿一次请求(含其内所有子调用/协程)的全局链路 ID
# ---------------------------------------------------------------------------
_trace_id_var: ContextVar[str] = ContextVar("aiflow_trace_id", default="-")
def new_trace_id() -> str:
"""生成一个崭新的链路 ID(32 位 hex)。"""
return uuid.uuid4().hex
def set_trace_id(tid: str | None = None) -> str:
"""设置当前上下文的 trace_id;不传则自动生成。返回最终使用的 ID。"""
tid = tid or new_trace_id()
_trace_id_var.set(tid)
return tid
def get_trace_id() -> str:
"""读取当前上下文的 trace_id;未设置时返回占位 '-'。"""
return _trace_id_var.get()
class TraceIdFilter(logging.Filter):
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
从而让全平台既有日志自动带上链路 ID,零侵入。
"""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "trace_id"):
try:
record.trace_id = get_trace_id()
except Exception:
record.trace_id = "-"
return True
# 标准 logging 字段,JSON 序列化时需排除,避免与自定义 extra 冲突
_STD_ATTRS = set(logging.makeLogRecord({}).__dict__.keys()) | {
"message", "asctime", "msg", "trace_id", "exc_info", "stack_info", "args",
}
class JsonFormatter(logging.Formatter):
"""结构化 JSON 日志格式(通过环境变量 AIFLOW_JSON_LOGS=1 启用)。
默认不启用,避免破坏现有日志采集/人工查阅习惯。
"""
def format(self, record: logging.LogRecord) -> str:
data = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"trace_id": getattr(record, "trace_id", "-"),
"msg": record.getMessage(),
}
for k, v in record.__dict__.items():
if k not in _STD_ATTRS:
try:
json.dumps(v, ensure_ascii=False)
data[k] = v
except (TypeError, ValueError):
data[k] = str(v)
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data, ensure_ascii=False)
def get_logger(name: str | None = None) -> logging.Logger:
"""获取 logger(向后兼容现有用法)。trace_id 由 root filter 自动注入。"""
return logging.getLogger(name)
# ---------------------------------------------------------------------------
# span:结构化计时上下文管理器(耗时 + trace_id + 自定义字段)
# ---------------------------------------------------------------------------
class _Span:
"""同步/异步通用的计时 span。退出时记录 elapsed_ms 与状态。"""
def __init__(self, name: str, **fields):
self.name = name
self.fields = fields
self._logger = logging.getLogger("aiflow.span")
self._start = None
self.elapsed_ms = 0.0
def _enter(self):
self._start = time.perf_counter()
self._logger.info(
f"SPAN_START {self.name}",
extra={"span": self.name, "event": "start", "trace_id": get_trace_id(), **self.fields},
)
return self
def _exit(self, exc_type, exc, tb):
self.elapsed_ms = (time.perf_counter() - self._start) * 1000
status = "error" if exc_type else "ok"
self._logger.info(
f"SPAN_END {self.name} {self.elapsed_ms:.1f}ms",
extra={
"span": self.name,
"event": "end",
"elapsed_ms": round(self.elapsed_ms, 1),
"status": status,
"trace_id": get_trace_id(),
**self.fields,
},
)
return False # 不吞掉异常
# 同步协议
def __enter__(self):
return self._enter()
def __exit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
# 异步协议
async def __aenter__(self):
return self._enter()
async def __aexit__(self, exc_type, exc, tb):
return self._exit(exc_type, exc, tb)
def span(name: str, **fields):
"""创建一个计时 span。可同步或异步 with。
async with span("chat.send_message", conv_id=cid):
...
"""
return _Span(name, **fields)
__all__ = [
"new_trace_id",
"set_trace_id",
"get_trace_id",
"TraceIdFilter",
"JsonFormatter",
"get_logger",
"span",
]
+248
View File
@@ -0,0 +1,248 @@
"""
Dify 知识库客户端
================
封装与 Dify 知识库(v1 API)的交互:连通性测试、检索(RAG)、文本入库。
设计用于替换原 FastGPT 外部知识库对接。
已验证的接入点(2026-07-14 实测):
- Base: https://dify.qihuyun.com/v1
(直接 HTTP 访问会被 nginx 301 跳转到 HTTPS,必须 HTTPS 直连,
且内网自签证书需 verify=False)
- Auth: Bearer {api_key}
- GET /v1/datasets 列出知识库(可据此解析 dataset id)
- POST /v1/datasets/{id}/document/create-by-text 文本入库
- POST /v1/datasets/{id}/document/create-by-file 文件入库(multipart)
- POST /v1/datasets/{id}/retrieve 检索(RAG)
retrieve 必需完整的 retrieval_model 字典,否则返回 400。
"""
import logging
import threading
import time
import httpx
logger = logging.getLogger("aiflow.dify_kb")
# dataset id 缓存: key -> (dataset_id, ts)
_dataset_cache: dict = {}
_dataset_cache_lock = threading.Lock()
_DATASET_CACHE_TTL = 3600
def _normalize_base(api_url: str) -> str:
"""统一 base url -> https 形式,且不带 /v1 后缀。"""
u = (api_url or "").strip()
if not u:
u = "https://dify.qihuyun.com/v1"
u = u.rstrip("/")
if u.endswith("/v1"):
u = u[: -len("/v1")]
if u.startswith("http://"):
u = "https://" + u[len("http://"):]
return u
def dify_test(api_url: str, api_key: str) -> dict:
"""验证 Dify 知识库「可通讯 + 可用」。
返回:
{
"connected": bool,
"usable": bool,
"status": "connected" | "auth_error" | "timeout" | "error",
"detail": str,
"datasets": [{"id":..., "name":...}, ...]
}
"""
base = _normalize_base(api_url)
headers = {"Authorization": f"Bearer {api_key}"}
try:
with httpx.Client(base_url=base, timeout=15, verify=False) as client:
r = client.get("/v1/datasets", headers=headers)
if r.status_code == 200:
data = r.json()
datasets = data.get("data", []) or []
names = [d.get("name") for d in datasets]
# 能列出知识库即代表鉴权通过、可读取 —— 视为可用。
return {
"connected": True,
"usable": True,
"status": "connected",
"detail": f"Dify 连接成功,发现 {len(datasets)} 个知识库: {', '.join([n for n in names if n]) or '无'}",
"datasets": [{"id": d.get("id"), "name": d.get("name")} for d in datasets],
}
elif r.status_code == 401:
return {
"connected": True,
"usable": False,
"status": "auth_error",
"detail": "已连通 Dify,但 API Key 鉴权失败(401),请检查 API Key。",
"datasets": [],
}
else:
return {
"connected": False,
"usable": False,
"status": "error",
"detail": f"Dify 返回状态码 {r.status_code}",
"datasets": [],
}
except httpx.TimeoutException:
return {"connected": False, "usable": False, "status": "timeout", "detail": "连接超时", "datasets": []}
except Exception as e:
return {"connected": False, "usable": False, "status": "error", "detail": str(e), "datasets": []}
def resolve_dataset_id(api_url: str, api_key: str, dataset_name: str = None) -> str:
"""解析 dataset id(优先按名称匹配,否则取列表第一个),带缓存。"""
base = _normalize_base(api_url)
headers = {"Authorization": f"Bearer {api_key}"}
cache_key = f"{base}|{api_key}|{dataset_name}"
now = time.time()
with _dataset_cache_lock:
c = _dataset_cache.get(cache_key)
if c and now - c[1] < _DATASET_CACHE_TTL:
return c[0]
ds_id = None
try:
with httpx.Client(base_url=base, timeout=15, verify=False) as client:
r = client.get("/v1/datasets", headers=headers)
if r.status_code == 200:
datasets = r.json().get("data", []) or []
if dataset_name:
match = [d for d in datasets if d.get("name") == dataset_name]
if match:
ds_id = match[0].get("id")
if not ds_id and datasets:
ds_id = datasets[0].get("id")
except Exception as e:
logger.error(f"resolve_dataset_id failed: {e}")
if ds_id:
with _dataset_cache_lock:
_dataset_cache[cache_key] = (ds_id, now)
return ds_id
def dify_retrieve(api_url, api_key, query, top_k=5, dataset_name=None) -> list:
"""检索,返回 [{content, score, source, metadata}]。"""
ds_id = resolve_dataset_id(api_url, api_key, dataset_name)
if not ds_id:
return []
base = _normalize_base(api_url)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"query": query,
"retrieval_model": {
"search_method": "semantic_search",
"reranking_enable": False,
"score_threshold_enabled": False,
"top_k": top_k,
},
}
try:
with httpx.Client(base_url=base, timeout=30, verify=False) as client:
r = client.post(f"/v1/datasets/{ds_id}/retrieve", headers=headers, json=payload)
if r.status_code == 200:
data = r.json()
out = []
for item in data.get("records", []):
seg = item.get("segment", {})
out.append({
"content": seg.get("content", ""),
"score": float(item.get("score", 0.5)),
"source": f"Dify: {seg.get('document_id', '')}",
"metadata": item,
})
return out
else:
logger.warning(f"Dify retrieve returned {r.status_code}: {r.text[:200]}")
return []
except Exception as e:
logger.error(f"Dify retrieve error: {e}")
return []
def dify_ingest_text(api_url, api_key, text, name=None, dataset_name=None) -> bool:
"""文本入库(create-by-text)。返回是否成功。"""
if not text or not text.strip():
return False
ds_id = resolve_dataset_id(api_url, api_key, dataset_name)
if not ds_id:
return False
base = _normalize_base(api_url)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"name": name or f"doc-{int(time.time())}",
"text": text,
"indexing_technique": "high_quality",
}
try:
with httpx.Client(base_url=base, timeout=60, verify=False) as client:
r = client.post(f"/v1/datasets/{ds_id}/document/create-by-text", headers=headers, json=payload)
if r.status_code in (200, 201):
return True
else:
logger.warning(f"Dify ingest text returned {r.status_code}: {r.text[:200]}")
return False
except Exception as e:
logger.error(f"Dify ingest text error: {e}")
return False
async def aingest_text(api_url, api_key, text, name=None, dataset_name=None) -> bool:
"""异步文本入库(供 chat 实时落库使用)。"""
if not text or not text.strip():
return False
ds_id = resolve_dataset_id(api_url, api_key, dataset_name)
if not ds_id:
return False
base = _normalize_base(api_url)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"name": name or f"doc-{int(time.time())}",
"text": text,
"indexing_technique": "high_quality",
}
try:
async with httpx.AsyncClient(base_url=base, timeout=60, verify=False) as client:
r = await client.post(f"/v1/datasets/{ds_id}/document/create-by-text", headers=headers, json=payload)
if r.status_code in (200, 201):
return True
else:
logger.warning(f"Dify aingest returned {r.status_code}: {r.text[:200]}")
return False
except Exception as e:
logger.error(f"Dify aingest error: {e}")
return False
def get_dify_kb_context(db, query: str, top_k: int = 5) -> str:
"""读取已启用的 Dify 外部服务,检索并返回拼接后的上下文文本(供 chat 注入)。"""
from sqlalchemy import text as _text
try:
result = db.execute(_text(
"SELECT api_url, api_key, config FROM external_kb_services "
"WHERE service_type='dify' AND is_enabled=1 AND status='connected' LIMIT 1"
))
row = result.fetchone()
if not row:
return ""
api_url, api_key, config_raw = row
dataset_name = None
if config_raw:
try:
cfg = config_raw if isinstance(config_raw, dict) else __import__("json").loads(config_raw)
dataset_name = cfg.get("dataset_name")
except Exception:
pass
hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
if not hits:
return ""
parts = []
for i, h in enumerate(hits, 1):
content = (h.get("content") or "").strip()
if content:
parts.append(f"[{i}] {content}")
return "\n\n".join(parts)
except Exception as e:
logger.error(f"get_dify_kb_context error: {e}")
return ""
+25 -117
View File
@@ -1,7 +1,7 @@
"""
知识库路由层
============
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(FastGPT/Dify/RAGFlow)。
将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
根据知识库配置自动选择最优检索策略。
使用方式:
@@ -9,17 +9,14 @@
results = await kb_router_search(query, kb_id, top_k=5)
"""
import os
import json
import logging
import httpx
from typing import List, Dict, Optional
logger = logging.getLogger("aiflow.kb_router")
async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
"""获取已配置的外部KB服务"""
"""获取已配置的外部KB服务(仅 dify)"""
from sqlalchemy import text
try:
query = "SELECT * FROM external_kb_services"
@@ -34,123 +31,34 @@ async def _get_external_services(db_session, enabled_only: bool = True) -> List[
return []
async def _search_fastgpt(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
"""搜索 FastGPT 知识库"""
async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""检索 Dify 知识库(走 app.dify_kb 客户端)"""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{api_url.rstrip('/')}/api/v1/dataset/search",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"query": query,
"similarity": 0.5,
"limit": top_k,
},
)
if resp.status_code == 200:
data = resp.json()
results = []
for item in data.get("data", {}).get("list", []):
results.append({
"content": item.get("q", item.get("content", "")),
"score": float(item.get("score", 0.5)),
"source": f"FastGPT: {item.get('collectionId', '')}",
"metadata": item,
})
return results
else:
logger.warning(f"FastGPT search returned {resp.status_code}")
return []
except Exception as e:
logger.error(f"FastGPT search error: {e}")
return []
async def _search_dify(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
"""搜索 Dify 知识库"""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{api_url.rstrip('/')}/v1/datasets/retrieval",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"query": query,
"retrieval_mode": "semantic",
"top_k": top_k,
},
)
if resp.status_code == 200:
data = resp.json()
results = []
for item in data.get("records", []):
segment = item.get("segment", {})
results.append({
"content": segment.get("content", ""),
"score": float(item.get("score", 0.5)),
"source": f"Dify: {segment.get('id', '')}",
"metadata": item,
})
return results
else:
logger.warning(f"Dify search returned {resp.status_code}")
return []
from app.dify_kb import dify_retrieve
api_url = service.get("api_url", "")
api_key = service.get("api_key", "")
dataset_name = None
cfg = service.get("config")
if isinstance(cfg, str):
try:
import json
cfg = json.loads(cfg)
except Exception:
cfg = None
if isinstance(cfg, dict):
dataset_name = cfg.get("dataset_name")
hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
return hits
except Exception as e:
logger.error(f"Dify search error: {e}")
return []
async def _search_ragflow(api_url: str, api_key: str, query: str, top_k: int = 5) -> List[Dict]:
"""搜索 RAGFlow 知识库"""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{api_url.rstrip('/')}/api/v1/retrieval",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"question": query,
"top_k": top_k,
},
)
if resp.status_code == 200:
data = resp.json()
results = []
for item in data.get("data", {}).get("chunks", []):
results.append({
"content": item.get("content", ""),
"score": float(item.get("similarity", 0.5)),
"source": f"RAGFlow: {item.get('document_id', '')}",
"metadata": item,
})
return results
else:
logger.warning(f"RAGFlow search returned {resp.status_code}")
return []
except Exception as e:
logger.error(f"RAGFlow search error: {e}")
return []
async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
"""路由到对应的外部KB服务"""
"""路由到对应的外部KB服务(当前仅支持 dify)"""
service_type = service.get("service_type", "")
api_url = service.get("api_url", "")
api_key = service.get("api_key", "")
if service_type == "fastgpt":
return await _search_fastgpt(api_url, api_key, query, top_k)
elif service_type == "dify":
return await _search_dify(api_url, api_key, query, top_k)
elif service_type == "ragflow":
return await _search_ragflow(api_url, api_key, query, top_k)
if service_type == "dify":
return await _search_dify(service, query, top_k)
else:
logger.warning(f"Unknown external KB type: {service_type}")
return []
@@ -171,7 +79,7 @@ async def kb_router_search(
检索策略:
1. 本地 BM25 检索 (MySQL, 已有)
2. 向量语义检索 (PostgreSQL+pgvector, 新增)
3. 外部KB检索 (FastGPT/Dify/RAGFlow, 按配置)
3. 外部KB检索 (Dify, 按配置)
融合方式: Reciprocal Rank Fusion (RRF)
@@ -218,7 +126,7 @@ async def kb_router_search(
except Exception as e:
logger.warning(f"Vector search failed: {e}")
# 3. 外部KB检索
# 3. 外部KB检索 (Dify)
if use_external and db_session:
services = await _get_external_services(db_session)
for service in services:
@@ -228,7 +136,7 @@ async def kb_router_search(
all_results.append({
"content": item.get("content", ""),
"score": item.get("score", 0.5),
"source": item.get("source", f"external_{service['service_type']}"),
"source": item.get("source", "external_dify"),
"rank": rank + 1,
"metadata": item.get("metadata", {}),
})
+187
View File
@@ -0,0 +1,187 @@
"""
MCP (Model Context Protocol) 客户端 —— 异步实现 streamable_http 传输。
支持:
- initialize / notifications/initialized / tools/list / tools/call 标准流程
- streamable_http:POST JSON-RPC,响应可能是 SSE(event: message / data: {...})或纯 JSON
- SSE 解析兼容
- 直连外部端点(trust_env=False,不绕容器代理)
供 chat.py 在 AI 对话中把 MCP 工具暴露为 function calling,并代理执行 tools/call。
"""
import json
import logging
import asyncio
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2024-11-05"
# 工具列表缓存(url -> (timestamp, tools)),避免同一请求内 / 短时间重复握手
_TOOLS_CACHE: dict = {}
_TOOLS_CACHE_TTL = 120 # 秒
def _parse_sse(text: str):
"""从响应文本解析 JSON-RPC 结果。兼容 SSE(data: 行)与纯 JSON。"""
if not text:
return None
text = text.strip()
# SSE 格式:多行 "event: message" / "data: {...}"
data_lines = []
for line in text.splitlines():
ls = line.strip()
if ls.startswith("data:"):
data_lines.append(ls[5:].strip())
if data_lines:
last = data_lines[-1]
try:
return json.loads(last)
except Exception:
pass
# 纯 JSON
try:
return json.loads(text)
except Exception:
return None
async def _handshake_and_request(
url: str,
headers: dict,
method: str,
params: dict,
timeout: float = 30.0,
):
"""
完成一次完整 MCP 会话(initialize -> initialized -> 目标请求)。
返回解析后的 JSON-RPC 响应 dict,或 None。
"""
base_headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
base_headers.update(headers or {})
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
# 1) initialize
r = await client.post(
url,
headers=base_headers,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "aiflow", "version": "1.0"},
},
},
)
if r.status_code >= 400:
logger.warning(f"[MCP] initialize 失败 {url} -> HTTP {r.status_code}")
return None
sid = r.headers.get("mcp-session-id") or r.headers.get("Mcp-Session-Id")
req_headers = dict(base_headers)
if sid:
req_headers["Mcp-Session-Id"] = sid
# 2) initialized 通知(不需要响应)
try:
await client.post(
url,
headers=req_headers,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
)
except Exception:
pass
# 3) 目标请求
rid = 2 if method != "tools/call" else 3
r2 = await client.post(
url,
headers=req_headers,
json={
"jsonrpc": "2.0",
"id": rid,
"method": method,
"params": params,
},
)
if r2.status_code >= 400:
logger.warning(f"[MCP] {method} 失败 {url} -> HTTP {r2.status_code}")
return None
return _parse_sse(r2.text)
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
"""
拉取 MCP 服务器的工具列表。
server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。
"""
url = (server.get("url") or "").strip()
if not url:
return []
# 缓存
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
try:
data = await _handshake_and_request(
url, server.get("headers") or {}, "tools/list", {}, timeout
)
tools = []
if data and "result" in data:
tools = data["result"].get("tools", []) or []
_TOOLS_CACHE[url] = (now, tools)
return tools
except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
return []
async def call_mcp_tool(
server: dict, tool_name: str, arguments: dict, timeout: float = 60.0
) -> str:
"""
在 MCP 服务器上执行一个工具,返回可供 LLM 消费的文本结果。
"""
url = (server.get("url") or "").strip()
if not url:
return f"[MCP 工具 {tool_name} 执行失败:服务端地址为空]"
logger.info(f"[MCP] 执行工具调用 tool={tool_name} server={server.get('name')} url={url}")
try:
data = await _handshake_and_request(
url,
server.get("headers") or {},
"tools/call",
{"name": tool_name, "arguments": arguments or {}},
timeout,
)
if not data:
return f"[MCP 工具 {tool_name} 调用失败:无响应]"
if "error" in data:
err = data["error"]
return f"[MCP 工具 {tool_name} 返回错误] {json.dumps(err, ensure_ascii=False)}"
result = data.get("result", {})
content = result.get("content", []) or []
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
else:
parts.append(json.dumps(item, ensure_ascii=False))
text = "\n".join(parts).strip()
if result.get("isError"):
return f"[MCP 工具 {tool_name} 执行报错]\n{text}"
return text or f"[MCP 工具 {tool_name} 返回空结果]"
except Exception as e:
logger.warning(f"[MCP] 工具执行异常 {tool_name} @ {url}: {e}")
return f"[MCP 工具 {tool_name} 执行异常:{e}]"
+2 -1
View File
@@ -57,7 +57,7 @@ class LogService:
app_id: str = None, app_name: str = None,
conversation_id: str = None, prompt_tokens: int = 0,
completion_tokens: int = 0, total_tokens: int = 0,
cost: float = 0):
cost: float = 0, trace_id: str = None):
"""记录Token消耗日志"""
db = SessionLocal()
try:
@@ -73,6 +73,7 @@ class LogService:
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost=cost,
trace_id=trace_id,
)
db.add(log)
db.commit()
+37
View File
@@ -5,6 +5,7 @@ AIFlow 数据库模型定义 - SQLAlchemy ORM
import uuid
from datetime import datetime, date
from sqlalchemy import (
Text,
Column, String, Integer, BigInteger, Float, Boolean,
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
DECIMAL, UniqueConstraint
@@ -86,6 +87,7 @@ class Conversation(Base):
knowledge_graph_count = Column(Integer, default=0)
knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID")
source = Column(String(32), default=None, comment="对话来源,如 wechat_bot")
ext_agent_conversation_id = Column(String(128), default=None, comment="外部Agent(Dify/Coze)会话ID,用于维持多轮上下文")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -102,7 +104,10 @@ 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="该轮对话调用的进化技能名列表")
used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表")
created_at = Column(DateTime, default=datetime.now)
@@ -292,7 +297,11 @@ class MarketApp(Base):
install_count = Column(Integer, default=0)
skill_content = Column(Text, default=None)
mcp_transport = Column(String(20), default=None)
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
agent_endpoint = Column(String(500), default=None)
agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)")
tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -530,6 +539,7 @@ class TokenUsageLog(Base):
app_id = Column(String(36), default=None)
app_name = Column(String(200), default=None)
conversation_id = Column(String(36), default=None)
trace_id = Column(String(64), default=None)
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
@@ -642,6 +652,10 @@ class SmsConfig(Base):
api_secret = Column(String(200), nullable=False)
sign_name = Column(String(100), default=None)
template_code = Column(String(100), default=None)
# 短信宝 (SMSBao) 字段
username = Column(String(100), default=None) # 短信宝用户名 u
password = Column(String(200), default=None) # 短信宝密码(明文存储,发送时 MD5 32位)
goods_id = Column(String(100), default=None) # 短信宝专属通道产品 ID g
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -737,6 +751,7 @@ class Asset(Base):
content = Column(Text, default=None)
novel_meta = Column(JSON, default=None)
status = Column(String(10), nullable=False, default="active") # active / deleted
knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -1099,3 +1114,25 @@ class MCPServer(Base):
resources_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class SmsVerificationCode(Base):
__tablename__ = "sms_verification_codes"
id = Column(String(36), primary_key=True, default=gen_uuid)
phone = Column(String(20), nullable=False, index=True)
code = Column(String(10), nullable=False)
purpose = Column(String(30), nullable=False) # register / reset_password
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
class ConversationSummary(Base):
__tablename__ = "conversation_summaries"
conversation_id = Column(String(64), primary_key=True)
summary_text = Column(Text, nullable=False, default="")
token_count = Column(Integer, default=0)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
+86
View File
@@ -0,0 +1,86 @@
"""
七虎AI平台 — 全功能知识库与能力导航
注入到每轮AI对话中,确保AI回复优先使用平台已有知识和能力。
"""
import logging
logger = logging.getLogger("aiflow")
PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
你是七虎AI(qihuyun.com)平台的AI助手,你必须充分了解并使用以下平台能力来回答用户问题,**优先引用平台内部知识而非互联网搜索**。
## 1. AI聊天(核心功能)
- **多模型支持**: DeepSeek-V4-Flash(默认)、DeepSeek-V3、通义千问、OpenAI等,可按能力自动匹配(Auto模式)
- **Auto智能匹配**: 根据用户意图自动选择最佳模型——文本对话用DeepSeek-V4-Flash、图像生成用agnes-image-2.1-flash、视频生成用agnes-video-v2.0、语音识别用MiMo-ASR
- **强大提示**: 图片理解(OCR用DeepSeek-OCR)、思维链推理、函数调用(Function Calling)
- **Markdown格式化**: 回复使用Markdown格式(标题/加粗/列表/代码块/表格),确保清晰、有层次、有逻辑
- **多模态生成**: AI绘图(文生图)、AI视频(文生视频)
- **对话管理**: 新建会话、历史管理、消息分页
## 2. 知识库系统
- **文档管理**: 上传txt/md/csv/pdf/docx/xlsx/pptx等文件,自动分块索引
- **智能检索**: BM25+语义检索,RAG增强回复
- **Dify集成**: 接入Dify外部知识库,深度RAG检索,AI聊天产出+用户文档自动入库量化
- **知识图谱**: 文档关系可视化、节点关联
## 3. 技能市场
- **Skills工具**: 从skills.sh、ClawHub、GitHub、ModelScope、SkillsMP等平台导入技能,支持SKILL.md格式验证
- **智能Agent**: 角色定制、行为指引、自动执行任务
- **MCP协议应用**: 从ModelScope MCP、MCP.so、MCPMarket导入MCP服务,支持Streamable HTTP/SSE传输协议,AI代码验证
- **导入Hub**: 一键从公共平台导入Skills/MCP应用,AI自动验证代码完整性
- **自定义创建**: 手动创建Skills/MCP/Agent应用,配置价格和发布
## 4. 进化看板
- **自进化体系**: AI对话轨迹自动分析、技能权重排序、原子化学习
- **进化仪表盘**: 技能贡献度、轨迹图谱、进化历史可视化
- **Pipeline调度**: 定期扫描对话、提取高价值轨迹、更新技能链接
## 5. IM Bot(微信集成)
- **个人微信Bot**: 通过iLink官方接口接入,扫码绑定,微信内直接AI对话
- **企业微信/飞书/QQ/悟空IM**: Webhook框架已就绪,支持自定义机器人
- **统一会话管理**: 微信消息并入平台AI聊天,统一历史管理
## 6. 资源中心
- **Token驾驶舱**: 用量统计(按日/模型)、剩余额度、消耗趋势图表
- **会员订阅**: 免费版/专业版/企业版,享受不同Token配额和功能权限
- **资产文件**: 上传管理图片/视频/文档等文件,关联知识库和AI聊天
## 7. 商业服务
- **计费系统**: Token计费、订单管理、支付渠道(支持多种支付)
- **广告管理**: 首页广告位、轮播图、付费推广
- **推广系统**: 邀请码、分销返佣、等级激励机制
- **成就系统**: 用户行为奖励、里程碑徽章、积分兑换
## 8. 管理后台(Admin)
- **数据驾驶舱**: 平台概览(用户数/模型数/对话数/Token消耗)、30天趋势图、操作日志/审计日志(分页+CSV导出)
- **大模型库**: 模型供应商管理(DeepSeek/OpenAI/SiliconFlow等)、模型配置(能力标签/超时/定价)、能力默认模型设置(11项能力)
- **用户管理**: 用户列表、会员等级、角色权限(RBAC)
- **系统设置**: 网站基础配置、能力默认值、MCP全局代理
- **短信配置**: SMSBao短信服务,测试发送/余额查询
- **隐私与安全**: GDPR设置、MCP隐私策略、数据加密
## 9. 开发者工具
- **API密钥管理**: 创建/撤销API密钥,调用平台API
- **MCP Client管理**: MCP服务器配置(stdio/sse/http)、环境变量、连接测试
- **自动化**: 定时任务调度
- **代码沙箱**: 安全代码执行环境
## 10. 基础设施
- **数据存储**: MySQL主库+Redis缓存+PG向量库+Elasticsearch(可选)
- **智能路由代理**: 国内/香港双通道智能分流,容器透明代理
- **部署**: Docker容器化,Nginx反向代理,FastAPI+React前后端分离
- **安全**: JWT鉴权、HTTPS加密、操作审计日志
## AI回复规范
1. **回复格式**: 使用Markdown(标题 `##`/`###`、加粗 `**`、列表 `-`/`1.`、代码块等)
2. **层次分明**: 使用标题分隔段落,确保信息有层次
3. **平台优先**: 回答问题时优先引用本平台功能,而非泛泛建议
4. **准确具体**: 基于平台文档给出具体操作路径、配置方法、API示例
5. **结构清晰**: 复杂问题先概括再分点,使用编号列表或表格
"""
def get_platform_knowledge() -> str:
"""获取平台全功能知识库文本,注入到AI对话系统提示中"""
return PLATFORM_KNOWLEDGE
+168
View File
@@ -0,0 +1,168 @@
"""
短信发送客户端(可复用)
支持:短信宝 (SMSBao)、阿里云短信。
所有发送函数接收一个 SmsConfig ORM 行对象(或具有相同字段的对象)作为配置。
设计:
- 不依赖 Web 框架,纯工具函数,供 auth / profile / token_usage / admin.settings 复用。
- 短信宝走国内直连,必须禁用代理(requests proxies={"http":None,"https":None})。
"""
import hashlib
import logging
import urllib.parse
import uuid
logger = logging.getLogger("aiflow")
# ============================================================
# 通用辅助
# ============================================================
def smsbao_md5(password: str) -> str:
"""短信宝密码:登录密码的 MD5(32位,不区分大小写)"""
return hashlib.md5(password.encode("utf-8")).hexdigest()
SMSBAO_ERRORS = {
"0": "发送成功",
"30": "密码错误",
"40": "账号不存在",
"41": "余额不足",
"43": "IP地址限制",
"50": "内容含有敏感词",
"51": "手机号码不正确",
}
def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str:
"""阿里云签名机制 (HMAC-SHA1)"""
sorted_keys = sorted(params.keys())
qs = "&".join([
f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}"
for k in sorted_keys
])
string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}"
key = f"{secret}&"
import hmac
import base64
h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1)
return base64.b64encode(h.digest()).decode('utf-8')
def _wrap_sign(config_sign: str) -> str:
"""包裹短信签名:缺少【】时自动加【】"""
sign = config_sign or ""
if "【" not in sign:
sign = f"【{sign}】" if sign else "【七虎AI】"
return sign
# ============================================================
# 核心发送
# ============================================================
def send_sms(config, phone: str, content: str):
"""
发送任意内容的短信。
:param config: SmsConfig ORM 行(需有 provider/username/password/goods_id/sign_name/api_key/api_secret/template_code)
:param phone: 接收手机号
:param content: 已包含签名的短信正文
:return: (ok: bool, message: str, provider_code: str|None)
"""
if not config or not getattr(config, "provider", None):
return False, "短信服务未配置,请联系管理员", None
provider = config.provider
# 短信宝 (SMSBao)
if provider == "短信宝":
if not config.username or not config.password:
return False, "短信宝账号未配置(缺少用户名或密码)", None
pwd_md5 = smsbao_md5(config.password)
params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content}
if getattr(config, "goods_id", None):
params["g"] = config.goods_id
import requests
try:
resp = requests.get(
"https://api.smsbao.com/sms", params=params, timeout=10,
proxies={"http": None, "https": None},
)
code_str = resp.text.strip().split("\n")[0]
except Exception as e:
logger.error(f"短信宝请求失败: {e}")
return False, f"短信宝请求失败: {e}", None
if code_str == "0":
return True, "发送成功", code_str
return False, SMSBAO_ERRORS.get(code_str, f"未知错误码 {code_str}"), code_str
# 阿里云短信
if "阿里云" in provider:
if not config.api_key or not config.api_secret:
return False, "阿里云短信 AccessKey 未配置", None
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
# 内容里已含验证码,阿里云模板需要 TemplateParam;此处用通用签名+内容方式不便,
# 为兼容现有配置,直接以单变量模板发送(TemplateParam 透传 content)。
from datetime import datetime as _dt
params = {
"AccessKeyId": config.api_key,
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": sign.replace("【", "").replace("】", ""),
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"TemplateCode": getattr(config, "template_code", None) or "SMS_00000001",
"TemplateParam": '{"code":"000000"}',
"Timestamp": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"Version": "2017-05-25",
}
params["Signature"] = _aliyun_sign(config.api_secret, params)
import requests
try:
resp = requests.post(
"https://dysmsapi.aliyuncs.com/",
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
result = resp.json()
except Exception as e:
logger.error(f"阿里云短信请求失败: {e}")
return False, f"阿里云短信请求失败: {e}", None
if result.get("Code") == "OK":
return True, "发送成功", result.get("Code")
return False, result.get("Message", result.get("Code", "未知错误")), result.get("Code")
return False, f"不支持的短信服务商: {provider}", None
# ============================================================
# 业务封装
# ============================================================
_PURPOSE_TEXT = {
"register": "注册",
"reset_password": "重置密码",
"change_password": "修改密码",
"recharge": "充值",
}
def send_sms_code(config, phone: str, code: str, purpose: str):
"""发送验证码类短信。"""
purpose_text = _PURPOSE_TEXT.get(purpose, "验证")
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
content = f"{sign}您的{purpose_text}验证码为{code},5分钟内有效,请勿泄露给他人。"
return send_sms(config, phone, content)
def send_sms_text(config, phone: str, text: str):
"""发送通知类短信(如充值成功、密码已修改等)。text 不含签名,自动包裹。"""
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
content = f"{sign}{text}"
return send_sms(config, phone, content)
+12
View File
@@ -0,0 +1,12 @@
"""Pytest 根配置:保证测试运行时 backend 包根目录在 sys.path 中。
运行方式(容器内,使用项目 venv):
cd /app/backend/python
/app/backend/python/.venv/bin/python -m pytest
"""
import os
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
+9 -4
View File
@@ -15,15 +15,17 @@ from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.core.observability import set_trace_id
from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
@@ -40,6 +42,7 @@ class APITrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
set_trace_id(request_id)
start_time = time.time()
path = request.url.path
method = request.method
@@ -52,6 +55,7 @@ class APITrackingMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
elapsed = time.time() - start_time
status_code = response.status_code
response.headers["X-Trace-Id"] = request_id
if not should_skip:
access_logger.info(
@@ -192,7 +196,6 @@ app.add_middleware(
# 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
@@ -231,10 +234,12 @@ app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", t
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@@ -242,4 +247,4 @@ app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
@app.get("/app/api/health")
async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
return {"status": "ok", "service": "Qihu-AIFlow"}
+11
View File
@@ -0,0 +1,11 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts = -q --strict-markers
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
+4
View File
@@ -0,0 +1,4 @@
# Phase 0 测试基建 — 开发/测试依赖(不进入生产 requirements.txt)
pytest==8.3.4
pytest-asyncio==0.24.0
httpx==0.27.2
View File
Whitespace-only changes.
+114
View File
@@ -0,0 +1,114 @@
"""Phase 2 context-engine regression: _build_chat_history budget/layering/summary.
Pure unit test with a fake DB session — no network, no real model. Guards the
layered-memory + compression + isolation behaviour against regressions.
"""
from app.api.chat_context import _estimate_tokens, _build_chat_history
class _Msg:
def __init__(self, role, content, cid="c1"):
self.role = role
self.content = content
self.conversation_id = cid
class _Summary:
def __init__(self, text):
self.summary_text = text
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *a, **k):
return self
def order_by(self, *a, **k):
# test pre-sorts newest-first to match desc(created_at)
return self
def limit(self, n=None):
return self
def all(self):
return self._rows
def first(self):
return self._rows[0] if self._rows else None
class _DB:
def __init__(self, messages, summary=None):
self._messages = messages
self._summary = summary
def query(self, model):
if getattr(model, "__name__", "") == "ConversationSummary":
return _Query([self._summary] if self._summary else [])
return _Query(self._messages)
def _newest_first(msgs):
# _build_chat_history expects desc(created_at) -> newest first.
# Test authors write the list already newest-first, so return as-is.
return list(msgs)
def test_estimate_tokens():
assert _estimate_tokens("") == 0
assert _estimate_tokens(None) == 0
assert _estimate_tokens("abcd") == 2 # 4//2
assert _estimate_tokens("a" * 5) == 2 # max(1, 5//2)=2
# just ensure positive monotonic
assert _estimate_tokens("x" * 100) == 50
def test_history_excludes_latest_user():
# newest-first: [current user, prev assistant, older user]
msgs = _newest_first([
_Msg("user", "current question"),
_Msg("assistant", "previous answer"),
_Msg("user", "older question"),
])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
roles = [m["role"] for m in out]
# current user dropped; chronological order = [older user, previous assistant]
assert roles == ["user", "assistant"]
assert out[0]["content"] == "older question"
def test_summary_injected_as_system_first():
msgs = _newest_first([
_Msg("user", "hi"),
_Msg("assistant", "hello"),
])
db = _DB(msgs, summary=_Summary("用户想做图片生成"))
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
assert out[0]["role"] == "system"
assert out[0]["content"].startswith("【对话历史摘要")
assert "用户想做图片生成" in out[0]["content"]
# after summary: chronological history (assistant then user 'hi' excluded? exclude_latest_user drops 'hi')
assert out[1]["role"] == "assistant"
def test_budget_truncation():
# 5 assistant messages each ~200 tokens; budget small -> truncated
msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=500, exclude_latest_user=False)
# each msg ~200 tokens; budget 500 -> keep newest ~2
assert len(out) <= 3
total = sum(_estimate_tokens(m["content"]) for m in out)
assert total <= 500 + 200 # within budget (+1 msg slack)
def test_no_summary_when_absent():
msgs = _newest_first([_Msg("user", "hi"), _Msg("assistant", "hello")])
db = _DB(msgs)
out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True)
# 'hi' excluded; only assistant remains, no system summary
assert all(m["role"] != "system" for m in out)
assert [m["role"] for m in out] == ["assistant"]
+20
View File
@@ -0,0 +1,20 @@
"""应用市场场景分类器回归(稳定模块,无外部依赖)。
仅校验分类法结构本身不被破坏;不调用 _classify_scene(其内部会请求 LLM)。
分类法是前端筛选/徽章的数据源,结构回归会导致全站分类错乱。
"""
from app.api.market import MARKET_SCENES
def test_market_scenes_is_14_flat():
assert len(MARKET_SCENES) == 14
def test_market_scenes_unique_ids_and_required_keys():
ids = [s["id"] for s in MARKET_SCENES]
assert len(set(ids)) == 14 # 无重复 id
for s in MARKET_SCENES:
assert "id" in s
assert "label" in s
assert "desc" in s
assert isinstance(s["label"], str) and s["label"].strip()
@@ -0,0 +1,87 @@
"""Phase 1 回归用例 R1/R2/R3 — 固化三个反复出现的问题(适配 chat.py 拆分后)。
R1: 技能列表不得被硬性截断为 15 条(微信 Bot 曾只返回 15 个技能)。
R2: 显式选中技能/MCP 时,system prompt 必须注入「最高优先级覆盖规则」。
R3: 媒体生成(图像/视频)必须有超时保护,超时后回写用户可懂的提示而非永久挂起。
Phase 1 已将 chat.py 巨石拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py,
因此本文件采用「扫描整个 app/api 包」做源码契约守卫,并对行为级用例从正确的子模块导入。
"""
import asyncio
import os
import glob
from unittest.mock import MagicMock
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
API_DIR = os.path.abspath(os.path.join(_HERE, "..", "app", "api"))
def _read_api_package():
parts = []
for p in sorted(glob.glob(os.path.join(API_DIR, "chat*.py"))):
with open(p, "r", encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
# ---------- R1 ----------
def test_r1_skill_list_not_hard_capped_at_15():
"""微信 Bot 技能列表曾因 limit(15) 只返回 15 个,必须防止回归。"""
src = _read_api_package()
assert "limit(15)" not in src, "检测到 limit(15),技能列表可能被硬性截断"
assert "LIMIT 15" not in src, "检测到 LIMIT 15,技能列表可能被硬性截断"
# ---------- R2 ----------
def test_r2_explicit_selection_override_rule_present():
"""显式选中技能/MCP 时,system prompt 必须包含最高优先级覆盖规则。
注:_build_system_prompt 已随 Phase 1 拆分迁入 chat_context.py,故扫描整个 api 包。
"""
src = _read_api_package()
assert "最高优先级覆盖规则" in src, "显式选中工具的覆盖规则文本缺失"
assert "只基于上文提供的该工具具体资料作答" in src, "覆盖规则应要求只基于已选工具资料作答"
assert "平台知识库" in src and "不要引用" in src, "覆盖规则应明确禁止引用平台知识库通用描述"
# ---------- R3 ----------
@pytest.mark.asyncio
async def test_r3_media_generation_timeout_returns_user_message(monkeypatch):
"""媒体生成超时必须回写用户可懂的超时提示,而非让前端永远『生成中…』。
_generate_media_task 位于 chat_media(经 _send_message_core 的 asyncio.create_task 调用),
本身是 fire-and-forget、返回 None,超时保护体现在把提示回写到消息 content。
"""
from app.api import chat_media as media_mod
class FakeModel:
id = "m_test"
capabilities = ["image"] # 图像 -> 90s 超时
msg_mock = MagicMock() # 捕获被回写的消息对象
fake_session = MagicMock()
def _query_side_effect(model_cls):
q = MagicMock()
if model_cls is media_mod.AIModel:
q.filter.return_value.first.return_value = FakeModel()
elif model_cls is media_mod.Message:
q.filter.return_value.first.return_value = msg_mock
return q
fake_session.query.side_effect = _query_side_effect
monkeypatch.setattr(media_mod, "SessionLocal", lambda: fake_session)
# 内部生成调用直接抛出 TimeoutError,等价模拟「生成耗时超过超时阈值」
async def _hang(db, ai_model, prompt, user_id):
raise asyncio.TimeoutError()
monkeypatch.setattr(media_mod, "_generate_media", _hang)
await media_mod._generate_media_task("msg1", "m_test", "画一只猫", "u1")
# 副作用:消息 content 被回写为用户可懂的超时提示
assert msg_mock.content is not None, "超时后必须回写消息内容,不能让前端永远『生成中…』"
assert "超时" in str(msg_mock.content), "媒体生成超时应回写用户可懂的超时提示"
+21
View File
@@ -0,0 +1,21 @@
"""Phase 0 安全网 — 后端冒烟测试。
核心价值:证明整个后端(含 chat.py 等巨型模块)在测试环境下能无错误导入并挂载路由。
任何破坏 import 的脆弱补丁都会在此立刻变红,从而把"改了没生效 / 启动即崩"挡在部署前。
"""
def test_app_imports_and_has_routes():
from main import app
assert app is not None
# 真实路由数远超 0;若 chat.py 等模块 import 失败,本测试在 import 阶段即报错
assert len(app.routes) > 50
def test_openapi_schema_builds():
from main import app
schema = app.openapi()
assert "paths" in schema
assert len(schema["paths"]) > 0
+21
View File
@@ -0,0 +1,21 @@
"""短信宝脚手架回归(稳定模块,纯函数)。
验证登录密码 MD5 算法与短信宝约定一致(32 位小写十六进制)。
这是短信验证码功能(注册/改密/充值)能连通短信宝的前提。
"""
import hashlib
from app.sms_client import smsbao_md5
def test_smsbao_md5_matches_hashlib():
samples = ["XHR521yhf", "abc123", "密码测试", ""]
for pw in samples:
assert smsbao_md5(pw) == hashlib.md5(pw.encode("utf-8")).hexdigest()
def test_smsbao_md5_is_32_lower_hex():
out = smsbao_md5("XHR521yhf")
assert len(out) == 32
assert out == out.lower()
assert all(c in "0123456789abcdef" for c in out)
+206
View File
@@ -0,0 +1,206 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** 七虎AI
**Generated:** 2026-07-13 09:07:15
**Category:** AI PaaS Platform / Developer Console
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#5B5FED` | `--color-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| CTA/Accent | `#5B5FED` | `--color-cta` |
| Background | `#F8F9FB` | `--color-background` |
| Text | `#101214` | `--color-text` |
**Color Notes:** Indigo #5B5FED is the single brand hue. Success #219B5E / Warning #E8922C / Error #D9383A / Info #3B82F6 are semantic only. Do not introduce extra brand colors.
### Typography
- **Heading Font:** Inter
- **Body Font:** Inter
- **Mood:** neutral, precise, technical
- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700;800;900|JetBrains+Mono:wght@400;500;600)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
```
### Spacing Variables
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #5B5FED;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #5B5FED;
border: 2px solid #5B5FED;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #F8F9FB;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #5B5FED;
outline: none;
box-shadow: 0 0 0 3px #5B5FED20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Warm Tech Minimalism
**Keywords:** clean, precise, data-dense, soft surfaces, indigo accent, generous whitespace
**Best For:** AI PaaS consoles, developer platforms, agent/workflow editors
**Key Effects:** Subtle hover lift (translateY -1 to -2px), 150-300ms ease transitions, soft shadow escalation, skeleton shimmer for loading, fade/slide micro-entrances.
### Page Pattern
**Pattern Name:** App Shell + Dashboard Grid
- **Conversion Strategy:** Core value shown immediately via workspace/chat; primary action (新建对话/部署) persistent in header and sidebar.
- **CTA Placement:** Sticky header + sidebar primary action; contextual CTAs inside cards.
- **Section Order:** Sidebar > Workspace(Chat/Agents/Skills/MCP) > Main content grid > Detail drawer
---
## Anti-Patterns (Do NOT Use)
- ❌ Ornate decoration
- ❌ Low contrast text
- ❌ Missing focus states
- ❌ Emojis as icons
- ❌ Layout-shifting scale hovers
- ❌ Instant state changes
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis as icons (use SVG: Heroicons/Lucide)
- [ ] cursor-pointer on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard nav
- [ ] prefers-reduced-motion respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
+102
View File
@@ -0,0 +1,102 @@
# 版本号规范 (Versioning)
> 适用:七虎AI PaaS 平台 (qihuaiflow) 全部发布物(源码、Docker 镜像、线上环境)。
> 生效版本:`v2026.07.18.1`(取代旧 `-recover` 标签体系)。
---
## 1. 版本号格式
```
vYYYY.MM.DD.N
```
| 段 | 含义 | 示例 |
|----|------|------|
| `YYYY` | 年(4 位) | 2026 |
| `MM` | 月(2 位,补零) | 07 |
| `DD` | 日(2 位,补零) | 18 |
| `N` | **当日**递增序号,从 `1` 开始,同日多次发布顺延 | 1, 2, 3 … |
- 同一天第 1 次发布 = `v2026.07.18.1`,第 2 次 = `v2026.07.18.2`。
- 跨天序号**重置**:次日第 1 次发布 = `v2026.07.19.1`。
- 序号不与全局总发布数绑定,只跟"当天第几次"有关,便于人类快速判断发布日期与顺序。
## 2. 环境标签 (prod / test)
每个版本必须标注环境,用于正式版与测试版的环境切割:
- **`prod`** — 正式版,面向最终用户,质量门禁(测试全绿 + 备份完成)必须达标。
- **`test`** — 测试版 / 灰度 / 预发布,允许未完全冻结,但同样需要镜像备份。
环境信息记录在 `VERSION` 文件的 `ENV=` 字段,并写入 git tag 注解与镜像 metadata。
## 3. 三类发布物的对应关系
一次发布同时产生三个相互关联的制品:
| 制品 | 命名 | 工具 |
|------|------|------|
| Git 标签 | `v2026.07.18.1`(注解含 `env=prod`) | `git tag -a` |
| Docker 镜像 | `qihu-aiflow:v2026.07.18.1` | `docker tag` / `docker commit` |
| 运行态快照(回滚点) | `qihu-aiflow:backup-YYYYMMDD-NNN` | `scripts/backup_before_deploy.py` |
> **关键纪律**:`backup-*` 是部署**前**对当前健康运行态的快照(防线);`v*` 镜像/标签是部署**后**的发布物(成果)。两者不是一个东西。
## 4. 发布流程(SOP)
每次部署严格遵守以下步骤:
```bash
# 0. 在改任何代码之前,先打部署前镜像备份(强制)
python scripts/backup_before_deploy.py --env prod --note "发布 v2026.07.18.1 前快照"
# 1. 在容器中完成代码修改
# (编辑 /app/... → 必要时 npm run build → docker restart)
# 2. 把容器内最新源码 docker cp 回宿主仓库
docker cp qihu-aiflow:/app /www/wwwroot/qihuaiflow # 仅后端;前端 dist 同理
# 3. 更新 VERSION 文件(VERSION / ENV / DATE / BACKUP_IMAGE)
# 4. 提交并打标签
cd /www/wwwroot/qihuaiflow
git add -A
git commit -m "release: v2026.07.18.1 (prod) — <一句话变更摘要>"
git tag -a v2026.07.18.1 -m "prod | Phase0 工程纪律止血:备份SOP + 版本规范 + 测试基建"
git push origin main
git push origin v2026.07.18.1
# 5. 固化发布镜像(可选但推荐,便于回滚到有版本号的镜像)
docker commit qihu-aiflow qihu-aiflow:v2026.07.18.1
```
## 5. 回滚流程
若发布后发现问题:
```bash
# 方案 A:回滚到部署前快照(最快,不依赖源码)
docker tag qihu-aiflow:backup-20260718-001 qihu-aiflow:with-deps
docker restart qihu-aiflow
# 方案 B:回滚到某个历史发布镜像
docker tag qihu-aiflow:v2026.07.18.1 qihu-aiflow:with-deps
docker restart qihu-aiflow
# 方案 C:源码回滚
git checkout v2026.07.18.1
docker cp /www/wwwroot/qihuaiflow/app qihu-aiflow:/app
docker restart qihu-aiflow
```
## 6. VERSION 文件维护
- 仓库根目录 `VERSION` 为机器 + 人共读的清单,必须包含 `VERSION=` / `ENV=` / `DATE=` / `BASE_IMAGE=` / `BACKUP_IMAGE=` 五个字段。
- 每次发布更新 `VERSION` 与 `DATE`,并在"本版本变更"小节追加一行摘要。
- `BACKUP_IMAGE` 必须填写本次发布前实际生成的 `backup-*` 标签,作为可审计的回滚点。
## 7. 旧标签处理
- 旧 `-recover` 系列标签(`v2026.07.12-recover` 等)**保留不删**,仅停止使用,避免丢失历史快照索引。
- 旧镜像标签 `AIFlow v2.0.1` / `v2026.07.14` / `v2026.07.14-nav` 视为历史资产,新发布不再沿用该命名。
+1 -7
View File
@@ -4,16 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qihu-AIFlow - 智能自动化PaaS平台</title>
<title>七虎AI</title>
</head>
<body>
<div id="root"></div>
<script>
// HashRouter redirect: ensure hash exists on first load
if (!window.location.hash) {
window.location.hash = '#/';
}
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35 -30
View File
@@ -1,30 +1,35 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/qrcode": "^1.5.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/qrcode": "^1.5.6",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"katex": "^0.17.0",
"mermaid": "^11.16.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^5.4.21",
"vitest": "^4.1.10"
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import LandingPage from '@/pages/front/LandingPage';
export function App() {
return React.createElement(
MemoryRouter,
{ initialEntries: ['/'] },
React.createElement(LandingPage)
);
}
+92
View File
@@ -0,0 +1,92 @@
/* 静态预渲染:把 LandingPage 渲染为静态 HTML 注入 dist/index.html 的 #root,
让 Google / Bing / ChatGPT / Perplexity 等不执行 JS 的爬虫也能读到真实正文。
同时生成 robots.txt 与 sitemap.xml。 */
const esbuild = require('esbuild');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const fs = require('fs');
const path = require('path');
const FRONT = '/app/frontend';
const OUT = path.join(FRONT, 'dist');
// 1) 用 esbuild 把组件打包成 CJS(react/react-dom/react-router-dom 保持 external,复用运行时同一实例)
esbuild.buildSync({
entryPoints: [path.join(FRONT, 'prerender-entry.tsx')],
bundle: true,
format: 'cjs',
platform: 'node',
outfile: path.join(FRONT, 'prerender-bundle.cjs'),
alias: { '@': path.join(FRONT, 'src') },
loader: { '.css': 'empty', '.tsx': 'tsx', '.ts': 'ts' },
jsx: 'automatic',
external: ['react', 'react-dom', 'react-router-dom'],
logLevel: 'info',
});
// 2) 服务端渲染为静态标记
const mod = require(path.join(FRONT, 'prerender-bundle.cjs'));
const ssrHtml = ReactDOMServer.renderToStaticMarkup(React.createElement(mod.App));
console.log('[prerender] ssrHtml length =', ssrHtml.length);
// 3) 注入 dist/index.html 的 #root
let html = fs.readFileSync(path.join(OUT, 'index.html'), 'utf8');
const rootOpen = '<div id="root"></div>';
if (html.includes(rootOpen)) {
html = html.replace(rootOpen, '<div id="root">' + ssrHtml + '</div>');
} else {
html = html.replace(/<div id="root">[\s\S]*?<\/div>\s*<\/body>/,
'<div id="root">' + ssrHtml + '</div></body>');
}
// 4) SEO/GEO <head> 元数据:替换已有 title/description(避免重复),再注入 og/canonical/JSON-LD
const title = '七虎AI · 个人体验 AI、办公提效与 AI 创收的一站式平台';
const desc = '七虎AI 是面向个人与职场用户的 AI 平台:低价订阅无限 Token、垂直办公模板、可交易技能市场、AI 创作试炼场与低成本养模型,让每个人用 AI 把事做成。';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: '七虎AI',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
aggregateRating: { '@type': 'AggregateRating', ratingValue: '4.8', reviewCount: '1000' },
url: 'https://ai.qihuyun.com/',
};
// 替换/新增 title 与 description(避免与源模板重复)
html = html.replace(/<title>[\s\S]*?<\/title>/, ` <title>${title}</title>`);
if (/<meta name="description"/.test(html)) {
html = html.replace(/<meta name="description"[^>]*>/, ` <meta name="description" content="${desc}" />`);
} else {
html = html.replace('</head>', ` <meta name="description" content="${desc}" />\n </head>`);
}
// 注入 og / canonical / JSON-LD(仅在尚不存在时,防止重复注入)
const extra = [
` <meta property="og:title" content="${title}" />`,
` <meta property="og:description" content="${desc}" />`,
` <meta property="og:type" content="website" />`,
` <meta name="twitter:card" content="summary_large_image" />`,
` <link rel="canonical" href="https://ai.qihuyun.com/" />`,
` <script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`,
].join('\n');
if (!html.includes('rel="canonical"')) {
html = html.replace('</head>', extra + '\n </head>');
}
fs.writeFileSync(path.join(OUT, 'index.html'), html);
// 5) robots.txt
fs.writeFileSync(path.join(OUT, 'robots.txt'),
'User-agent: *\nAllow: /\n\nSitemap: https://ai.qihuyun.com/sitemap.xml\n');
// 6) sitemap.xml(当前公开可索引页仅首页;其余为登录后页面,按需在后续迭代补充)
const urls = ['https://ai.qihuyun.com/'];
const sitemap = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
urls.map(u => ` <url><loc>${u}</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`).join('\n') +
'\n</urlset>\n';
fs.writeFileSync(path.join(OUT, 'sitemap.xml'), sitemap);
// 7) 清理临时文件
[path.join(FRONT, 'prerender-entry.tsx'), path.join(FRONT, 'prerender-bundle.cjs')].forEach(f => fs.existsSync(f) && fs.unlinkSync(f));
console.log('PRERENDER DONE');
+10 -2
View File
@@ -13,7 +13,6 @@ import AdminLayout from '@/components/layout/AdminLayout';
const LandingPage = React.lazy(() => import('@/pages/front/LandingPage'));
const LoginPage = React.lazy(() => import('@/pages/front/LoginPage'));
const ChatPage = React.lazy(() => import('@/pages/front/ChatPage'));
const ChannelPage = React.lazy(() => import('@/pages/front/ChannelPage'));
const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage'));
const MarketPage = React.lazy(() => import('@/pages/front/MarketPage'));
const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage'));
@@ -28,6 +27,7 @@ const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard'));
const AdminUsers = React.lazy(() => import('@/pages/admin/Users'));
const AdminModels = React.lazy(() => import('@/pages/admin/Models'));
const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings'));
const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig'));
const AdminPayment = React.lazy(() => import('@/pages/admin/Payment'));
const AdminServices = React.lazy(() => import('@/pages/admin/Services'));
const AdminMembership = React.lazy(() => import('@/pages/admin/Membership'));
@@ -45,6 +45,7 @@ const Achievement = React.lazy(() => import('@/pages/admin/Achievement'));
const Tenant = React.lazy(() => import('@/pages/admin/Tenant'));
const Billing = React.lazy(() => import('@/pages/admin/Billing'));
const Analytics = React.lazy(() => import('@/pages/admin/Analytics'));
const Observability = React.lazy(() => import('@/pages/admin/Observability'));
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
@@ -56,6 +57,8 @@ const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const pageFallback = <div className="loading-screen">加载中...</div>;
@@ -111,7 +114,6 @@ const App: React.FC = () => {
<Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
<Route path="chat/:id" element={<ChatPage />} />
<Route path="channels" element={<ChannelPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="market" element={<MarketPage />} />
<Route path="token" element={<TokenDashboardPage />} />
@@ -122,6 +124,8 @@ const App: React.FC = () => {
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} />
</Route>
{/* Admin Routes */}
@@ -130,6 +134,7 @@ const App: React.FC = () => {
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="sms" element={<AdminSmsConfig />} />
<Route path="payment" element={<AdminPayment />} />
<Route path="services" element={<AdminServices />} />
<Route path="membership" element={<AdminMembership />} />
@@ -147,6 +152,7 @@ const App: React.FC = () => {
<Route path="tenant" element={<Tenant />} />
<Route path="billing" element={<Billing />} />
<Route path="analytics" element={<Analytics />} />
<Route path="observability" element={<Observability />} />
<Route path="knowledge" element={<AdminKnowledge />} />
<Route path="workflow" element={<AdminWorkflow />} />
<Route path="storage" element={<AdminStorage />} />
@@ -159,6 +165,8 @@ const App: React.FC = () => {
<Route path="portal" element={<UserPortal />} />
</Route>
{/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */}
<Route path="/home" element={<LandingPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
+1238 -62
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+437 -124
View File
@@ -1,124 +1,437 @@
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="20" height="20" 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"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user || user.role !== 'admin') {
navigate('/login');
return null;
}
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav className="admin-nav">
{adminNavItems.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</nav>
<div className="admin-sidebar-footer">
<NavLink to="/chat" className="admin-nav-item">
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</span>
{!collapsed && <span className="nav-label">返回前台</span>}
</NavLink>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
{!collapsed && (
<div className="admin-version-info">
Qihu-AIFlow v2.0.0
</div>
)}
</aside>
{/* Admin Main Content */}
<main className="admin-main">
<header className="admin-topbar">
<div className="admin-topbar-left">
<span className="admin-welcome">欢迎,{user.username}</span>
</div>
<div className="admin-topbar-right">
<a href="#/chat" className="admin-topbar-action">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>返回前台</span>
</a>
<div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span>{user.username}</span>
</div>
</div>
</header>
<div className="admin-content">
<Outlet />
</div>
</main>
</div>
);
};
export default AdminLayout;
import React, { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
interface NavItem {
path: string;
icon: string;
label: string;
exact?: boolean;
}
interface NavGroup {
title: string;
icon: string;
items: NavItem[];
}
const adminNavGroups: NavGroup[] = [
{
title: '数据驾驶舱',
icon: 'dashboard',
items: [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
],
},
{
title: 'AI技能中心',
icon: 'agents',
items: [
{ path: '/admin/models', icon: 'models', label: '大模型库' },
{ path: '/admin/agents', icon: 'agents', label: 'Agent 管理' },
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
],
},
{
title: '平台运营',
icon: 'users',
items: [
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员管理' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
],
},
{
title: '商业变现',
icon: 'billing',
items: [
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道' },
{ path: '/admin/services', icon: 'services', label: '增值服务' },
{ path: '/admin/ads', icon: 'ads', label: '广告管理' },
{ path: '/admin/assets', icon: 'assets', label: '资产管理' },
],
},
{
title: '系统管理',
icon: 'settings',
items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/sms', icon: 'sms', label: '短信配置' },
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
],
},
{
title: '待处理',
icon: 'developer',
items: [
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
{ path: '/admin/observability', icon: 'observability', label: '调用可观测' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
{ path: '/admin/developer', icon: 'developer', label: '开发者 API' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
],
},
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="18" height="18" 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"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
agents: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>,
skills: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
code: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
distro: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
privacy: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/><circle cx="12" cy="16" r="1"/></svg>,
promotion: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="7"/><path d="M8.5 12.5L6 21l6-2 6 2-2.5-8.5"/><circle cx="12" cy="8" r="3"/></svg>,
achievement: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/></svg>,
tenant: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21V8l9-5 9 5v13"/><path d="M9 21v-6h6v6"/><path d="M3 13h18"/></svg>,
billing: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>,
analytics: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/><line x1="3" y1="20" x2="21" y2="20"/></svg>,
observability: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>,
imbot: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
storage: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
automation: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>,
developer: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
devtools: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
knowledge: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
workflow: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><line x1="10" y1="6" x2="14" y2="6"/><line x1="10" y1="17" x2="14" y2="17"/></svg>,
mcp: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
apps: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
assets: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
chevron: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>,
sms: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// 后台必须登录且为管理员
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
if (!token || !user || user.role !== 'admin') {
logout();
navigate('/login', { replace: true });
}
}, [user, logout, navigate]);
const navCrumb = (() => {
for (const g of adminNavGroups) {
for (const it of g.items) {
const match = it.exact
? location.pathname === it.path
: location.pathname === it.path || location.pathname.startsWith(it.path + '/');
if (match) return { group: g.title, label: it.label };
}
}
return { group: '', label: '' };
})();
const navGroup = navCrumb.group;
const navLabel = navCrumb.label;
// 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
return new Set(adminNavGroups.map((g) => g.title));
});
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(title)) next.delete(title);
else next.add(title);
return next;
});
};
const handleLogout = () => {
logout();
navigate('/');
};
const handleVisitFrontend = () => {
const url = window.location.origin + '/chat';
window.open(url, '_blank');
};
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar-header">
<h1 className="admin-logo">七虎AI</h1>
<span className="admin-subtitle">管理控制台</span>
</div>
<button onClick={handleVisitFrontend} className="admin-frontend-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span>访问前台</span>
</button>
<nav className="sidebar-nav">
{adminNavGroups.map((group) => {
const isExpanded = expandedGroups.has(group.title);
const hasActiveChild = group.items.some(item =>
item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path)
);
return (
<div key={group.title} className={`nav-group ${hasActiveChild ? 'nav-group-active' : ''}`}>
<div
className={`nav-group-header ${isExpanded ? 'expanded' : ''}`}
onClick={() => toggleGroup(group.title)}
>
<span className="nav-group-chevron-wrap">
<span className={`nav-group-chevron ${isExpanded ? 'chevron-expanded' : ''}`}>
{adminIconPaths.chevron}
</span>
</span>
<span className="nav-group-title">{group.title}</span>
</div>
{isExpanded && (
<div className="nav-group-items">
{group.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) =>
`nav-item ${isActive ? 'nav-item-active' : ''}`
}
>
<span className="nav-item-icon">{adminIconPaths[item.icon]}</span>
<span className="nav-item-label">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
);
})}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-info">
<div className="admin-user-avatar">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<div className="admin-user-detail">
<div className="admin-user-name">{user?.username || 'admin'}</div>
<button className="admin-logout-btn" onClick={handleLogout}>退出登录</button>
</div>
</div>
</div>
</aside>
<main className="admin-content">
<header className="admin-topbar">
<nav className="admin-breadcrumb" aria-label="breadcrumb">
<span className="crumb-root">管理控制台</span>
{navGroup && (<><span className="crumb-sep">/</span><span className="crumb-group">{navGroup}</span></>)}
{navLabel && (<><span className="crumb-sep">/</span><span className="crumb-current">{navLabel}</span></>)}
</nav>
<div className="admin-topbar-user">
<div className="admin-user-avatar sm">{user?.username?.[0]?.toUpperCase() || 'A'}</div>
<span className="admin-topbar-username">{user?.username || 'admin'}</span>
</div>
</header>
<Outlet />
</main>
</div>
);
};
export default AdminLayout;
+71 -19
View File
@@ -1,16 +1,18 @@
/* ===== Front Layout ===== */
/* ===== Front Layout V3 - Light Theme ===== */
.front-layout {
display: flex;
height: 100vh;
overflow: hidden;
background: #F5F7FC;
color: #1A1F36;
}
/* Sidebar */
.front-sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--color-sidebar-bg);
border-right: 1px solid var(--color-border-light);
background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFE 100%);
border-right: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transition: width var(--transition-slow), min-width var(--transition-slow);
@@ -29,7 +31,7 @@
justify-content: space-between;
padding: var(--space-lg) var(--space-lg);
height: var(--header-height);
border-bottom: 1px solid var(--color-border-light);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.sidebar-logo {
@@ -41,7 +43,7 @@
.sidebar-brand {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
color: #1A1F36;
letter-spacing: -0.02em;
white-space: nowrap;
}
@@ -53,13 +55,14 @@
width: 32px;
height: 32px;
border-radius: var(--radius-md);
color: var(--color-text-tertiary);
color: #8892B0;
transition: all var(--transition-fast);
cursor: pointer;
}
.collapse-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
background: rgba(0, 0, 0, 0.08);
color: #8892B0;
}
/* Navigation */
@@ -68,7 +71,7 @@
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--space-sm) var(--space-sm);
padding: var(--space-sm);
overflow-y: auto;
}
@@ -77,14 +80,23 @@
flex-direction: column;
gap: 2px;
}
.nav-group-title {
padding: 14px var(--space-md) 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9CA3AF;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
padding: 10px var(--space-md);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
color: #8892B0;
text-decoration: none;
font-size: var(--text-base);
font-weight: 500;
@@ -93,13 +105,13 @@
}
.nav-item:hover {
background: var(--color-sidebar-hover);
color: var(--color-text-primary);
background: rgba(91, 95, 237, 0.06);
color: #1A1F36;
}
.nav-item.active {
background: var(--color-sidebar-active);
color: var(--color-accent);
background: rgba(91, 95, 237, 0.12);
color: #5B5FED;
}
.nav-icon {
@@ -115,10 +127,30 @@
flex: 1;
}
/* Active dot indicator */
.nav-active-dot {
display: none;
}
.nav-item.active .nav-active-dot {
display: block;
position: absolute;
right: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #5B5FED;
box-shadow: 0 0 8px rgba(91, 95, 237, 0.5);
}
/* Sidebar Footer */
.sidebar-footer {
padding: var(--space-md);
border-top: 1px solid var(--color-border-light);
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.sidebar-footer .nav-item {
margin-top: 2px;
}
.user-info-mini {
@@ -126,13 +158,14 @@
align-items: center;
gap: var(--space-md);
padding: var(--space-sm);
margin-bottom: 4px;
}
.user-avatar-sm {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
background: linear-gradient(135deg, #5B5FED, #4A4ECC);
color: white;
display: flex;
align-items: center;
@@ -151,16 +184,21 @@
.user-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
color: #1A1F36;
line-height: 1.3;
}
.user-plan {
font-size: var(--text-xs);
color: var(--color-accent);
color: #5B5FED;
line-height: 1.3;
}
.logout-btn:hover {
color: #EF4444 !important;
background: rgba(239, 68, 68, 0.1) !important;
}
/* Main Content */
.front-main {
flex: 1;
@@ -168,6 +206,12 @@
display: flex;
flex-direction: column;
min-width: 0;
background: #F5F7FC;
}
/* Forward-slash route for apps - hidden default route */
.front-main > * {
height: 100%;
}
/* Responsive */
@@ -187,3 +231,11 @@
transform: translateX(0);
}
}
/* Home & Admin links */
.nav-home-link {
margin-bottom: 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
padding-bottom: 8px;
}
+77 -32
View File
@@ -3,19 +3,39 @@ import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './FrontLayout.css';
const navItems = [
{ path: '/chat', icon: 'chat', label: 'AI聊天' },
{ path: '/channels', icon: 'channel', label: '频道' },
{ path: '/knowledge', icon: 'knowledge', label: '知识库' },
{ path: '/market', icon: 'market', label: '应用市场' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
{ path: '/assets', icon: 'assets', label: '资产' },
{ path: '/evolution', icon: 'evolution', label: 'AI进化' },
const navGroups = [
{
title: '智能体',
items: [
{ path: '/chat', icon: 'chat', label: 'AI聊天' },
{ path: '/knowledge', icon: 'knowledge', label: '知识库' },
{ path: '/market', icon: 'market', label: '技能市场' },
{ path: '/evolution', icon: 'evolution', label: '进化看板' },
],
},
{
title: 'IM Bot',
items: [
{ path: '/wechat-bot', icon: 'wechat', label: '微信Bot' },
{ path: '/im-bots?platform=wecom', icon: 'wecom', label: '企业微信' },
{ path: '/im-bots?platform=feishu', icon: 'feishu', label: '飞书' },
{ path: '/im-bots?platform=qq', icon: 'qq', label: 'QQ' },
{ path: '/im-bots?platform=wukong', icon: 'wukong', label: '悟空IM' },
],
},
{
title: '资源中心',
items: [
{ path: '/app-services', icon: 'apps', label: '应用服务' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '会员订阅' },
{ path: '/assets', icon: 'assets', label: '资产文件' },
],
},
];
const bottomItems = [
{ path: '/profile', icon: 'profile', label: '个人中心' },
{ path: '/profile', icon: 'profile', label: '个人设置' },
{ path: '/notifications', icon: 'notify', label: '消息通知' },
];
@@ -24,12 +44,18 @@ const iconPaths: Record<string, JSX.Element> = {
channel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="9" height="9" rx="2"/><rect x="13" y="2" width="9" height="9" rx="2"/><rect x="2" y="13" width="9" height="9" rx="2"/><rect x="13" y="13" width="5" height="5" rx="1.5"/><path d="M18 18v3"/><path d="M19.5 16.5v6"/></svg>,
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>,
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
wechat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><circle cx="9" cy="11" r="1"/><circle cx="15" cy="11" r="1"/></svg>,
wecom: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>,
feishu: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.2 3.8a2 2 0 0 0-2.8 0L4 17.2V21h3.8L20.2 6.6a2 2 0 0 0 0-2.8z"/><path d="M14 7l3 3"/></svg>,
qq: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.5 9.5a1 1 0 0 0 0 1M15.5 9.5a1 1 0 0 0 0 1M9 15c1-1 5-1 6 0"/></svg>,
wukong: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></svg>,
};
const FrontLayout: React.FC = () => {
@@ -46,49 +72,71 @@ const FrontLayout: React.FC = () => {
<div className="front-layout">
{/* Sidebar */}
<aside className={`front-sidebar ${collapsed ? 'collapsed' : ''}`}>
{/* Header */}
<div className="sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="flGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#5B5FED"/>
<stop offset="100%" stopColor="#4A4ECC"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#flGrad)"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand">Qihu-AIFlow</span>}
{!collapsed && <span className="sidebar-brand">七虎AI</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
{collapsed ? <><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></> : <><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></>}
</svg>
</button>
</div>
{/* Navigation */}
<nav className="sidebar-nav">
<div className="nav-section">
{navItems.map(item => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{iconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
<span className="nav-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
{!collapsed && <span className="nav-item-label">返回首页</span>}
</NavLink>
{navGroups.map(group => (
<div className="nav-section" key={group.title}>
{!collapsed && <div className="nav-group-title">{group.title}</div>}
{group.items.map(item => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
title={collapsed ? item.label : undefined}
>
<span className="nav-icon">{iconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
{!collapsed && <span className="nav-active-dot" />}
</NavLink>
))}
</div>
))}
<div className="nav-section nav-bottom">
{bottomItems.map(item => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
title={collapsed ? item.label : undefined}
>
<span className="nav-icon">{iconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
{!collapsed && <span className="nav-active-dot" />}
</NavLink>
))}
</div>
</nav>
{/* Footer */}
<div className="sidebar-footer">
<div className="user-info-mini">
<div className="user-avatar-sm">
@@ -105,17 +153,14 @@ const FrontLayout: React.FC = () => {
<NavLink
to="/admin"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
title="返回后台"
title="管理后台"
>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.2"/>
<rect x="14" y="3" width="7" height="7" rx="1.2"/>
<rect x="3" y="14" width="7" height="7" rx="1.2"/>
<rect x="14" y="14" width="7" height="7" rx="1.2"/>
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
{!collapsed && <span className="nav-label">返回后台</span>}
{!collapsed && <span className="nav-label">管理后台</span>}
</NavLink>
)}
<button
+3 -3
View File
@@ -1,13 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<BrowserRouter>
<App />
</HashRouter>
</BrowserRouter>
</React.StrictMode>
);
+2 -8
View File
@@ -2,7 +2,7 @@
// 分离的Mock数据层,便于后续接入真实后端时替换
import type {
User, Conversation, Message, Channel, KnowledgeBase,
User, Conversation, Message, KnowledgeBase,
KnowledgeDocument, MarketApp, AppReview, TokenUsageLog,
TokenBalance, Order, MembershipPlan, Coupon, UserCoupon,
Notification, ModelProvider, AIModel, SiteSettings,
@@ -97,13 +97,7 @@ export const mockMessages: Record<string, Message[]> = {
],
};
// ==================== 频道数据 (IM账户绑定) ====================
export const mockChannels: Channel[] = [
{ id: 'ch1', userId: 'u1', platform: 'feishu', name: '飞书AI助手', accountName: 'zangsan@company.feishu.cn', agentId: 'a1', knowledgeBaseId: 'kb1', status: 'connected', lastActive: '2026-05-26T09:30:00Z', boundModel: 'DeepSeek-V3', features: ['AI对话', '文档问答', '任务提醒', '会议纪要'], createdAt: '2026-04-01T08:00:00Z' },
{ id: 'ch2', userId: 'u1', platform: 'dingtalk', name: '钉钉AI助理', accountName: '张经理', agentId: 'a2', knowledgeBaseId: 'kb2', status: 'connected', lastActive: '2026-05-26T08:15:00Z', boundModel: 'GPT-4o', features: ['AI对话', '审批辅助', '日程管理'], createdAt: '2026-04-15T10:00:00Z' },
{ id: 'ch3', userId: 'u1', platform: 'wechat', name: '微信AI伙伴', accountName: 'wxid_abc123', agentId: undefined, knowledgeBaseId: undefined, status: 'pending', lastActive: undefined, boundModel: 'DeepSeek-V3', features: ['AI对话', '内容创作'], createdAt: '2026-05-01T09:00:00Z' },
{ id: 'ch4', userId: 'u1', platform: 'telegram', name: 'Telegram Bot', accountName: '@aiflow_bot', agentId: undefined, knowledgeBaseId: undefined, status: 'disconnected', lastActive: '2026-05-10T14:00:00Z', boundModel: 'Qwen-Max', features: ['AI对话'], createdAt: '2026-05-10T14:00:00Z' },
];
// ==================== 知识库数据 ====================
export const mockKnowledgeBases: KnowledgeBase[] = [
+2 -1
View File
@@ -11,6 +11,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import { formatBjTime } from '@/utils/datetime';
@@ -1012,7 +1013,7 @@ const Achievement: React.FC = () => {
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '8px' }}>
完成时间: {new Date(ach.completed_at).toLocaleString('zh-CN')}
完成时间: {formatBjTime(ach.completed_at)}
</div>
+1 -1
View File
@@ -204,7 +204,7 @@ const Ads: React.FC = () => {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>广告位管理</h1>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>广告管理</h1>
<button className="admin-btn admin-btn-primary" onClick={handleCreate}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
新增广告位
+107 -3
View File
@@ -105,6 +105,10 @@ const Agents: React.FC = () => {
// Available skills for linking
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
// Available MCP servers for tool_policy sandbox allowlist
const [availableMcps, setAvailableMcps] = useState<any[]>([]);
// Tool policy (sandbox) — null means disabled / no policy
const [toolPolicy, setToolPolicy] = useState<any>(null);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
@@ -144,14 +148,17 @@ const Agents: React.FC = () => {
// Load available skills & models
const loadAvailable = async () => {
try {
const [skillsResult, modelsResult] = await Promise.all([
const [skillsResult, modelsResult, mcpsResult] = await Promise.all([
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
adminAPI.getAdminAvailableModels() as any,
adminAPI.getAdminMcpServers({ page_size: 50 }) as any,
]);
const skills = (skillsResult.data || skillsResult).items || [];
const models = (modelsResult.data || modelsResult) || [];
const mcps = (mcpsResult.data || mcpsResult).items || [];
setAvailableSkills(skills);
setAvailableModels(models);
setAvailableMcps(mcps);
} catch {}
};
@@ -187,6 +194,7 @@ const Agents: React.FC = () => {
memory_enabled: false,
reasoning_enabled: false,
});
setToolPolicy(null);
setShowModal(true);
};
@@ -194,6 +202,7 @@ const Agents: React.FC = () => {
const openEdit = async (agent: Agent) => {
setEditingAgent(agent);
await loadAvailable();
await openEditLoadPolicy(agent.id);
const cfg = agent.config || {};
setForm({
@@ -213,7 +222,15 @@ const Agents: React.FC = () => {
setShowModal(true);
};
// Open detail
// Fetch existing tool_policy (sandbox) for this agent
const openEditLoadPolicy = async (agentId: string) => {
try {
const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any;
setToolPolicy(tpRes?.data?.tool_policy || null);
} catch {
setToolPolicy(null);
}
};
const openDetail = async (agent: Agent) => {
setDetailAgent(agent);
setShowDetail(true);
@@ -246,7 +263,13 @@ const Agents: React.FC = () => {
try {
if (editingAgent) {
await adminAPI.updateAdminAgent(editingAgent.id, payload);
showToast('Agent 已更新');
// Phase 3.1: persist tool_policy sandbox (null clears it)
try {
await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy);
showToast('Agent 已更新');
} catch (pe: any) {
showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error');
}
} else {
await adminAPI.createAdminAgent(payload);
showToast('Agent 创建成功');
@@ -612,6 +635,87 @@ const Agents: React.FC = () => {
</div>
)}
</div>
{editingAgent && (
<div style={{ gridColumn: '1 / -1' }}>
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
<div style={{ fontWeight: 600, marginBottom: 12 }}>🛡️ 工具权限沙箱 (tool_policy)</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 12 }}>
<input type="checkbox" checked={toolPolicy !== null} onChange={e => {
if (e.target.checked) {
setToolPolicy({ allowed_tools: { skill_ids: [], mcp_ids: [] }, max_calls_per_conversation: null, per_round_cap: null, fallback_message: '' });
} else {
setToolPolicy(null);
}
}} />
启用工具/MCP 权限沙箱(限制该 Agent 可调用的工具与调用频次)
</label>
{toolPolicy && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">允许调用的技能(不选 = 禁止调用任何技能)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableSkills.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
) : availableSkills.map((skill: any) => {
const checked = (toolPolicy.allowed_tools?.skill_ids || []).includes(skill.id);
return (
<label key={skill.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
const cur = toolPolicy.allowed_tools?.skill_ids || [];
const next = checked ? cur.filter((x: string) => x !== skill.id) : [...cur, skill.id];
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), skill_ids: next } });
}} />
{skill.skill_name}
</label>
);
})}
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">允许调用的 MCP 服务(不选 = 禁止调用任何 MCP)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableMcps.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无 MCP 服务</span>
) : availableMcps.map((m: any) => {
const checked = (toolPolicy.allowed_tools?.mcp_ids || []).includes(m.id);
return (
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 12, fontSize: 12, cursor: 'pointer', background: checked ? '#EEF2FF' : '#F9FAFB', border: `1px solid ${checked ? '#6366F1' : '#E5E7EB'}`, color: checked ? '#6366F1' : '#374151' }}>
<input type="checkbox" style={{ display: 'none' }} checked={checked} onChange={() => {
const cur = toolPolicy.allowed_tools?.mcp_ids || [];
const next = checked ? cur.filter((x: string) => x !== m.id) : [...cur, m.id];
setToolPolicy({ ...toolPolicy, allowed_tools: { ...(toolPolicy.allowed_tools || {}), mcp_ids: next } });
}} />
{m.name}
</label>
);
})}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">单对话最大调用次数(留空=不限制)</label>
<input className="admin-form-input" type="number" min="0"
value={toolPolicy.max_calls_per_conversation ?? ''}
onChange={e => setToolPolicy({ ...toolPolicy, max_calls_per_conversation: e.target.value === '' ? null : parseInt(e.target.value) })} />
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">单轮最大调用次数(留空=不限制)</label>
<input className="admin-form-input" type="number" min="0"
value={toolPolicy.per_round_cap ?? ''}
onChange={e => setToolPolicy({ ...toolPolicy, per_round_cap: e.target.value === '' ? null : parseInt(e.target.value) })} />
</div>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label">额度耗尽提示语(可选)</label>
<textarea className="admin-form-input" rows={2}
value={toolPolicy.fallback_message || ''}
onChange={e => setToolPolicy({ ...toolPolicy, fallback_message: e.target.value })}
placeholder="例如:该智能体已达到工具调用上限,请开启新对话或调整智能体设置。" />
</div>
</div>
)}
</div>
)}
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import './Apps.css';
import { formatBjTime } from '@/utils/datetime';
interface AIApp {
id: string;
@@ -779,9 +780,9 @@ function AppDetailDrawer({ app, analytics, onClose, onAction }: {
{/* Timestamps */}
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
<div>创建时间: {app.created_at ? formatBjTime(app.created_at) : '-'}</div>
<div>更新时间: {app.updated_at ? formatBjTime(app.updated_at) : '-'}</div>
{app.published_at && <div>发布时间: {formatBjTime(app.published_at)}</div>}
</div>
</div>
+6 -23
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
import { formatBjTime } from '@/utils/datetime';
interface AutomationTask {
id: string;
@@ -136,25 +137,7 @@ const Automation: React.FC = () => {
useEffect(() => { loadStats(); }, []);
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
// Open create modal
const openCreateModal = () => {
setEditingTask(null);
setFormData({
name: '',
description: '',
trigger_type: 'cron',
cron_expression: '*/5 * * * *',
event_condition_str: '',
action_type: 'ai_pipeline',
action_config_str: '{"pipeline_name": "default"}',
is_enabled: true,
max_retries: 3,
timeout_seconds: 300,
});
setModalVisible(true);
};
// Open edit modal
// Open edit modal (tasks are created by AI in chat; admin only edits/enables existing ones)
const openEditModal = async (task: AutomationTask) => {
try {
const result = await adminAPI.getAdminAutomationTask(task.id) as any;
@@ -288,7 +271,7 @@ const Automation: React.FC = () => {
// Format time
const formatTime = (ts: string) => {
if (!ts) return '-';
try { return new Date(ts).toLocaleString('zh-CN'); }
try { return formatBjTime(ts); }
catch { return ts; }
};
@@ -311,7 +294,7 @@ const Automation: React.FC = () => {
<div className="admin-page">
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
<h2>自动化任务调度</h2>
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button>
<span className="admin-page-hint">任务由 AI 在聊天中自动创建,此处仅支持查看、编辑与启停</span>
</div>
{/* Stats Cards */}
@@ -445,7 +428,7 @@ const Automation: React.FC = () => {
{modalVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3>
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>编辑任务</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div><label style={labelStyle}>任务名称 *</label>
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
@@ -506,7 +489,7 @@ const Automation: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151' }}>取消</button>
<button onClick={handleSubmit} disabled={submitting} style={btnStyle('#6C63FF', submitting)}>
{submitting ? '保存中...' : (editingTask ? '保存修改' : '创建任务')}
{submitting ? '保存中...' : '保存修改'}
</button>
</div>
</div>
+457 -227
View File
@@ -1,227 +1,457 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats(),
adminAPI.getRecentLogs(),
adminAPI.getRecentAudit(),
]);
if (statsRes.data) setStats(statsRes.data);
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatTokens = (n: number): string => {
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCards = [
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value">{s.value}</div>
</div>
</div>
))}
</div>
{/* Platform Cumulative Token Stats */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}平台累计Token消耗
</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
{[
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
{item.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
{item.label}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
</div>
))}
</div>
</div>
{/* Quick Entry Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
</div>
))}
</div>
{/* Recent Operation Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
import { formatBjTime } from '@/utils/datetime';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
interface DashboardStats {
total_users: number;
active_users: number;
total_models: number;
enabled_models: number;
total_conversations: number;
active_conversations: number;
valid_conversations: number;
total_knowledge_bases: number;
total_documents: number;
today_orders: number;
today_subscription_orders: number;
today_tokens: number;
week_tokens: number;
total_tokens: number;
today_conversations: number;
server_time: string;
}
interface DailyStat {
date: string;
tokens: number;
}
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
// SVG Bar Chart Component
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
const maxVal = Math.max(...data.map(d => d.tokens), 1);
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
const barAreaW = w - padLeft - padRight;
const barAreaH = h - padTop - padBottom;
const barGap = 2;
const barW = Math.max(2, (barAreaW / data.length) - barGap);
// Y axis ticks
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
const formatDate = (d: string) => {
const parts = d.split('-');
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
};
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
{/* Grid lines */}
{yTicks.map((tick, i) => {
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
return (
<g key={i}>
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const barH = (d.tokens / maxVal) * barAreaH;
const x = padLeft + i * (barAreaW / data.length);
const y = padTop + barAreaH - barH;
return (
<g key={i}>
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
</rect>
{/* X label - every 7th bar */}
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
)}
</g>
);
})}
</svg>
);
};
// Mini Donut for stat cards
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
const pct = max > 0 ? Math.min(value / max, 1) : 0;
const r = 16, c = 20, circumference = 2 * Math.PI * r;
const dash = circumference * pct;
return (
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
{Math.round(pct * 100)}%
</text>
</svg>
);
};
const pagBtnStyle = (disabled: boolean): React.CSSProperties => ({
border: '1px solid rgba(0,0,0,0.12)',
background: disabled ? '#F3F4F6' : '#FFFFFF',
color: disabled ? '#B0B3BD' : '#374151',
borderRadius: 6,
padding: '4px 10px',
fontSize: 12,
cursor: disabled ? 'not-allowed' : 'pointer',
});
const LogPagination: React.FC<{ total: number; page: number; pageSize: number; onPage: (p: number) => void }> = ({ total, page, pageSize, onPage }) => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, padding: '12px 16px', fontSize: 12, color: '#6B7280' }}>
<span>共 {total} 条</span>
<button disabled={page <= 1} onClick={() => onPage(page - 1)} style={pagBtnStyle(page <= 1)}>上一页</button>
<span style={{ fontFamily: 'var(--font-mono)', color: '#374151' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPage(page + 1)} style={pagBtnStyle(page >= totalPages)}>下一页</button>
</div>
);
};
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats>({
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
total_conversations: 0, active_conversations: 0, valid_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
today_orders: 0, today_subscription_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
today_conversations: 0, server_time: '',
});
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [opTotal, setOpTotal] = useState(0);
const [auditTotal, setAuditTotal] = useState(0);
const [opPage, setOpPage] = useState(1);
const [auditPage, setAuditPage] = useState(1);
const [loading, setLoading] = useState(true);
const PAGE_SIZE = 20;
const loadOperationLogs = async (page: number) => {
try {
const res: any = await (adminAPI as any).getDashboardOperationLogs(page, PAGE_SIZE);
const data = res?.data || res;
setOperationLogs(Array.isArray(data?.items) ? data.items : []);
setOpTotal(data?.total ?? 0);
setOpPage(page);
} catch (err) {
console.error('操作日志加载失败:', err);
}
};
const loadAuditLogs = async (page: number) => {
try {
const res: any = await (adminAPI as any).getDashboardAuditLogs(page, PAGE_SIZE);
const data = res?.data || res;
setAuditLogs(Array.isArray(data?.items) ? data.items : []);
setAuditTotal(data?.total ?? 0);
setAuditPage(page);
} catch (err) {
console.error('审计日志加载失败:', err);
}
};
const downloadCsv = async (url: string, filename: string) => {
try {
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api${url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error('下载失败');
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(href);
} catch (err) {
console.error('CSV下载失败:', err);
alert('下载失败,请稍后重试');
}
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, dailyRes] = await Promise.all([
adminAPI.getStats() as any,
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: { data: { data: [] } } }),
]);
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
const statsData = statsRes?.data || statsRes;
if (statsData && typeof statsData === 'object') setStats(statsData);
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
await Promise.all([loadOperationLogs(1), loadAuditLogs(1)]);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string | null) => formatBjTime(d);
const formatTokens = (n: number): string => {
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCardDefs = [
{ label: '总用户', value: stats.total_users, sub: `近7天活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '对话数', value: stats.valid_conversations, sub: `活跃窗口 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
{ label: '知识库文档', value: stats.total_documents, sub: `知识库 ${stats.total_knowledge_bases}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
icon: <SVGIcon size={22}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon> },
{ label: '今日订阅成功', value: stats.today_subscription_orders, sub: `今日订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards Row */}
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
{statCardDefs.map((s, i) => (
<div key={i} className="admin-stat-card" style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
padding: 'var(--space-lg) var(--space-xl)',
}}>
<div style={{
width: 48, height: 48, borderRadius: 14,
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
</div>
<div style={{
fontSize: 11, color: s.color, marginTop: 2,
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
display: 'inline-block',
}}>{s.sub}</div>
</div>
</div>
))}
</div>
{/* Charts Row */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
{/* Token Trend Chart */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}Token消耗趋势(近30天)
</h3>
</div>
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
<BarChart data={dailyStats} />
</div>
</div>
{/* System Health */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
{' '}平台概览
</h3>
</div>
<div style={{ padding: 'var(--space-lg)' }}>
{[
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
</div>
))}
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
服务器时间: {stats.server_time ? formatBjTime(stats.server_time) : '-'}
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ marginBottom: 'var(--space-xl)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6C63FF';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}}
>
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Recent Logs */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">操作日志</h3>
<button
onClick={() => downloadCsv('/admin/dashboard/operation-logs/export', 'operation_logs.csv')}
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
>下载 CSV</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
<LogPagination total={opTotal} page={opPage} pageSize={PAGE_SIZE} onPage={loadOperationLogs} />
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">审计日志</h3>
<button
onClick={() => downloadCsv('/admin/dashboard/audit-logs/export', 'audit_logs.csv')}
style={{ fontSize: 12, color: '#6C63FF', border: '1px solid rgba(108,99,255,0.4)', background: 'rgba(108,99,255,0.06)', borderRadius: 6, padding: '4px 12px', cursor: 'pointer' }}
>下载 CSV</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
<LogPagination total={auditTotal} page={auditPage} pageSize={PAGE_SIZE} onPage={loadAuditLogs} />
</div>
</div>
);
};
export default Dashboard;
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
import { formatBjTime } from '@/utils/datetime';
const API_BASE = '/app/api';
@@ -216,7 +217,7 @@ export default function AdminDeveloper() {
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
try { return formatBjTime(t); } catch { return t; }
};
const statusBadge = (status: string) => {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
import { formatBjTime } from '@/utils/datetime';
const API_BASE = '/app/api';
@@ -169,7 +170,7 @@ export default function AdminDeveloperTools() {
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
try { return formatBjTime(t); } catch { return t; }
};
const statusBadge = (status: string) => {
+4 -179
View File
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import {
adminAPI,
} from '@/api';
import { formatBjTime } from '@/utils/datetime';
// Platform icons (simple SVG representations)
const platformIcons: Record<string, string> = {
@@ -33,7 +34,7 @@ const statusColors: Record<string, string> = {
disconnected: '#6b7280',
};
type Tab = 'dashboard' | 'channels' | 'routes' | 'langbot' | 'messages' | 'setup';
type Tab = 'dashboard' | 'channels' | 'routes' | 'messages' | 'setup';
const ImBots: React.FC = () => {
const [activeTab, setActiveTab] = useState<Tab>('dashboard');
@@ -46,7 +47,6 @@ const ImBots: React.FC = () => {
const [filterPlatform, setFilterPlatform] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [routes, setRoutes] = useState<any[]>([]);
const [langbotConfig, setLangbotConfig] = useState<any[]>([]);
const [messages, setMessages] = useState<any[]>([]);
const [messagesTotal, setMessagesTotal] = useState(0);
const [messagesPage, setMessagesPage] = useState(1);
@@ -77,18 +77,6 @@ const ImBots: React.FC = () => {
credentials: '',
});
// LangBot form state
const [langbotForm, setLangbotForm] = useState({
id: 1,
gateway_url: '',
api_key: '',
webhook_secret: '',
bot_uuid: '',
callback_url: '',
sync_channels: 0,
status: 'inactive',
});
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
@@ -155,22 +143,6 @@ const ImBots: React.FC = () => {
}
}, []);
// Load LangBot config
const loadLangbot = useCallback(async () => {
try {
const res = await adminAPI.getLangbotConfig();
if ((res as any)?.success) {
const configs = res.data || [];
setLangbotConfig(configs);
if (configs.length > 0) {
setLangbotForm({ ...langbotForm, ...configs[0] });
}
}
} catch (e) {
showToast('Failed to load LangBot config', 'error');
}
}, []);
// Load messages
const loadMessages = useCallback(async () => {
try {
@@ -211,8 +183,6 @@ const ImBots: React.FC = () => {
} else if (activeTab === 'routes') {
loadRoutes();
loadChannels();
} else if (activeTab === 'langbot') {
loadLangbot();
} else if (activeTab === 'messages') {
loadMessages();
}
@@ -348,51 +318,7 @@ const ImBots: React.FC = () => {
}
};
// Handle LangBot config save
const handleSaveLangbot = async () => {
try {
const params = new URLSearchParams({
config_id: String(langbotForm.id || 1),
gateway_url: langbotForm.gateway_url,
status: langbotForm.status,
sync_channels: String(langbotForm.sync_channels),
});
if (langbotForm.api_key) params.append('api_key', langbotForm.api_key);
if (langbotForm.webhook_secret) params.append('webhook_secret', langbotForm.webhook_secret);
if (langbotForm.bot_uuid) params.append('bot_uuid', langbotForm.bot_uuid);
if (langbotForm.callback_url) params.append('callback_url', langbotForm.callback_url);
await adminAPI.updateLangbotConfig('?' + params.toString());
showToast('LangBot config saved');
loadLangbot();
} catch (e) {
showToast('Failed to save LangBot config', 'error');
}
};
// Handle LangBot test
const handleTestLangbot = async () => {
if (!langbotForm.gateway_url) {
showToast('Please enter gateway URL', 'error');
return;
}
try {
const params = new URLSearchParams({
gateway_url: langbotForm.gateway_url,
});
if (langbotForm.api_key) params.append('api_key', langbotForm.api_key);
const res = await adminAPI.testLangbot('?' + params.toString());
if ((res as any)?.success) {
const data = res.data;
if (data.reachable) {
showToast(`LangBot reachable! Status: ${data.status_code}`);
} else {
showToast(`LangBot unreachable: ${data.error}`, 'error');
}
}
} catch (e) {
showToast('Test failed', 'error');
}
};
// Copy to clipboard
const copyToClipboard = (text: string) => {
@@ -404,14 +330,13 @@ const ImBots: React.FC = () => {
// Format time
const formatTime = (time: string) => {
if (!time) return '-';
return new Date(time).toLocaleString('zh-CN');
return formatBjTime(time);
};
const tabs: { key: Tab; label: string; icon: string }[] = [
{ key: 'dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ key: 'channels', label: '频道管理', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z' },
{ key: 'routes', label: '消息路由', icon: 'M3.5 18.5l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 17l1.5 1.5z' },
{ key: 'langbot', label: 'LangBot 网关', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z' },
{ key: 'messages', label: '消息日志', icon: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z' },
{ key: 'setup', label: '快速接入', icon: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z' },
];
@@ -422,7 +347,7 @@ const ImBots: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0 }}>IM 机器人管理</h1>
<p style={{ color: '#888', fontSize: '14px', margin: '4px 0 0 0' }}>管理即时通讯平台接入、消息路由和 LangBot 网关</p>
<p style={{ color: '#888', fontSize: '14px', margin: '4px 0 0 0' }}>管理即时通讯平台接入与消息路由</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{stats && (
@@ -430,7 +355,6 @@ const ImBots: React.FC = () => {
<StatBadge label="频道" value={stats.total_channels} color="#3370FF" />
<StatBadge label="在线" value={stats.active_channels} color="#10b981" />
<StatBadge label="今日消息" value={stats.today_messages_in + stats.today_messages_out} color="#f59e0b" />
<StatBadge label="LangBot" value={stats.langbot_status === 'active' ? 'ON' : 'OFF'} color={stats.langbot_status === 'active' ? '#10b981' : '#6b7280'} />
</div>
)}
</div>
@@ -744,105 +668,6 @@ const ImBots: React.FC = () => {
</div>
)}
{/* LangBot Tab */}
{activeTab === 'langbot' && (
<div style={{ maxWidth: '800px' }}>
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>LangBot 网关配置</h3>
<span style={{
padding: '4px 12px', borderRadius: '4px', fontSize: '12px',
background: langbotForm.status === 'active' ? '#10b98120' : '#6b728020',
color: langbotForm.status === 'active' ? '#10b981' : '#6b7280',
}}>
{langbotForm.status === 'active' ? '已启用' : '未启用'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<FormField label="网关 URL" >
<input
value={langbotForm.gateway_url}
onChange={(e) => setLangbotForm({ ...langbotForm, gateway_url: e.target.value })}
placeholder="http://localhost:5300"
style={inputStyle}
/>
</FormField>
<FormField label="Bot UUID">
<input
value={langbotForm.bot_uuid || ''}
onChange={(e) => setLangbotForm({ ...langbotForm, bot_uuid: e.target.value })}
placeholder="LangBot HTTP Bot UUID"
style={inputStyle}
/>
</FormField>
<FormField label="API Key">
<input
type="password"
value={langbotForm.api_key || ''}
onChange={(e) => setLangbotForm({ ...langbotForm, api_key: e.target.value })}
placeholder="LangBot REST API Key"
style={inputStyle}
/>
</FormField>
<FormField label="Webhook Secret">
<input
type="password"
value={langbotForm.webhook_secret || ''}
onChange={(e) => setLangbotForm({ ...langbotForm, webhook_secret: e.target.value })}
placeholder="HMAC-SHA256 签名密钥"
style={inputStyle}
/>
</FormField>
<FormField label="回调 URL">
<input
value={langbotForm.callback_url || ''}
onChange={(e) => setLangbotForm({ ...langbotForm, callback_url: e.target.value })}
placeholder="http://210.16.161.218:12888/app/api/channels/webhook/langbot"
style={inputStyle}
/>
</FormField>
<FormField label="自动同步频道">
<select
value={langbotForm.sync_channels}
onChange={(e) => setLangbotForm({ ...langbotForm, sync_channels: Number(e.target.value) })}
style={selectStyle}
>
<option value={0}>否</option>
<option value={1}>是</option>
</select>
</FormField>
<FormField label="状态">
<select
value={langbotForm.status}
onChange={(e) => setLangbotForm({ ...langbotForm, status: e.target.value })}
style={selectStyle}
>
<option value="inactive">未启用</option>
<option value="active">启用</option>
</select>
</FormField>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button onClick={handleSaveLangbot} style={primaryButtonStyle}>保存配置</button>
<button onClick={handleTestLangbot} style={secondaryButtonStyle}>测试连接</button>
</div>
</div>
{/* LangBot Info */}
<div style={{ background: '#1a1d27', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h4 style={{ marginTop: 0, fontSize: '14px', color: '#888' }}>LangBot 集成说明</h4>
<ul style={{ fontSize: '13px', color: '#aaa', lineHeight: '1.8', paddingLeft: '20px' }}>
<li>LangBot 是开源的 LLM 原生 IM 机器人平台,支持飞书、微信、钉钉等</li>
<li>配置 LangBot 作为外部网关后,IM 消息将通过 LangBot 转发到本平台</li>
<li>在 LangBot 中创建 HTTP Bot 适配器,将 Webhook URL 指向本平台</li>
<li>API Key 在 LangBot 的 config.yaml 中通过 api.global_api_key 配置</li>
<li>Webhook Secret 用于 HMAC-SHA256 双向签名验证</li>
</ul>
</div>
</div>
)}
{/* Messages Tab */}
{activeTab === 'messages' && (
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -68,7 +68,7 @@ const Market: React.FC = () => {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">应用市场审核管理</h1>
<h1 className="admin-page-title">AI技能审核管理</h1>
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-2xl)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
+4 -2
View File
@@ -40,6 +40,7 @@ const TRANSPORT_TYPES = [
{ value: '', label: '全部类型' },
{ value: 'stdio', label: 'STDIO' },
{ value: 'sse', label: 'SSE' },
{ value: 'streamable_http', label: 'HTTP 流式' },
];
const STATUS_TYPES = [
@@ -194,8 +195,9 @@ export default function AdminMcp() {
};
const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
const labels: Record<string, string> = { sse: 'SSE', stdio: '标准IO', streamable_http: 'HTTP 流式' };
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : type === 'streamable_http' ? { bg: '#d1fae5', color: '#065f46' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[type] || type}</span>;
};
const totalPages = Math.ceil(total / pageSize);
+1 -1
View File
@@ -94,7 +94,7 @@ const Membership: React.FC = () => {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">会员订阅体系管理</h1>
<h1 className="admin-page-title">会员管理</h1>
{/* Plan Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
+101 -21
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { adminAPI } from '@/api';
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
@@ -15,27 +15,20 @@ const formatNumber = (n: number) => {
};
const capabilityColors: Record<string, string> = {
text: '#5B5FED',
vision: '#219B5E',
audio: '#E8922C',
video: '#D9383A',
image: '#D9383A',
thinking: '#8B5CF6',
tool: '#3B82F6',
text: '#5B5FED', thinking: '#8B5CF6', vision: '#219B5E', tool: '#3B82F6',
image: '#D9383A', audio: '#E8922C', asr: '#0EA5E9', ocr: '#F59E0B',
embedding: '#10B981', rerank: '#EC4899', video: '#EF4444',
};
const capabilityLabels: Record<string, string> = {
text: '文本',
vision: '视觉',
audio: '音频',
video: '视频',
image: '图像',
thinking: '思考',
tool: '工具',
text: '文本对话', thinking: '推理思考', vision: '视觉理解', tool: '工具调用',
image: '图像生成', audio: '语音合成', asr: '语音识别', ocr: 'OCR识别',
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
};
// Test result type
interface TestResult {
modelId: string;
success: boolean;
message: string;
latency_ms?: number;
@@ -69,9 +62,21 @@ const Models: React.FC = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 能力默认模型(Auto 自动模式)
const [capabilities, setCapabilities] = useState<any[]>([]);
const [capabilityDefaults, setCapabilityDefaults] = useState<Record<string, string>>({});
const [capabilityModels, setCapabilityModels] = useState<any[]>([]);
const [savingCaps, setSavingCaps] = useState<Set<string>>(new Set());
// Model test state
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const testTimer = useRef<number | null>(null);
// 测试反馈自动消失(6 秒后)
useEffect(() => {
return () => { if (testTimer.current) clearTimeout(testTimer.current); };
}, []);
// Model editor state
const [showModelEditor, setShowModelEditor] = useState(false);
@@ -85,10 +90,11 @@ const Models: React.FC = () => {
const loadData = useCallback(async () => {
try {
setLoading(true);
const [provRes, modelsRes, protoRes] = await Promise.all([
const [provRes, modelsRes, protoRes, capRes] = await Promise.all([
adminAPI.getProviders(),
adminAPI.getAdminModels({ page_size: 100 }),
adminAPI.getProtocols(),
adminAPI.getCapabilityDefaults(),
]);
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
@@ -105,6 +111,11 @@ const Models: React.FC = () => {
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
}));
setModels(mappedModels);
// 能力默认模型
const cd = (capRes.data || capRes) as any;
setCapabilities(cd.capabilities || []);
setCapabilityDefaults(cd.defaults || {});
setCapabilityModels(cd.models || []);
} catch (err: any) {
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
} finally {
@@ -114,17 +125,21 @@ const Models: React.FC = () => {
useEffect(() => { loadData(); }, [loadData]);
// Test model connection
// Test model connection (single model only)
const handleTestConnection = async (modelId: string) => {
setTestingModelId(modelId);
setTestResult(null);
if (testTimer.current) { clearTimeout(testTimer.current); testTimer.current = null; }
try {
const result = await adminAPI.testModelConnection(modelId) as any;
setTestResult(result.success !== undefined ? result : (result.data || result as TestResult));
const r = result.success !== undefined ? result : (result.data || result);
setTestResult({ modelId, success: !!r.success, message: r.message || '', latency_ms: r.latency_ms });
} catch (err: any) {
setTestResult({ success: false, message: err.message || '测试失败' });
setTestResult({ modelId, success: false, message: err.message || '测试失败' });
} finally {
setTestingModelId(null);
// 6 秒后自动收起反馈
testTimer.current = window.setTimeout(() => setTestResult(null), 6000);
}
};
@@ -278,6 +293,23 @@ const Models: React.FC = () => {
}
};
// 设置/清除某个能力的默认模型(Auto 自动模式使用)
const handleSetCapabilityDefault = async (capId: string, modelId: string | null) => {
try {
setSavingCaps(prev => new Set(prev).add(capId));
const res = await adminAPI.setCapabilityDefault(capId, modelId) as any;
setCapabilityDefaults((res && res.defaults) || { ...capabilityDefaults, [capId]: modelId || undefined } as Record<string, string>);
} catch (err: any) {
setError(err.message || '设置失败');
} finally {
setSavingCaps(prev => {
const n = new Set(prev);
n.delete(capId);
return n;
});
}
};
// Toggle capability
const toggleCapability = (cap: string) => {
if (!editingModel) return;
@@ -437,8 +469,8 @@ const Models: React.FC = () => {
style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
{testingModelId === model.id ? '测试中...' : '连接测试'}
</button>
{/* Test Result Popover */}
{testResult && (
{/* Test Result Popover (only for the tested model) */}
{testResult && testResult.modelId === model.id && (
<div style={{ position: 'relative' }}>
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}>
{testResult.success ? '✓ 成功' : '✗ 失败'}
@@ -476,6 +508,54 @@ const Models: React.FC = () => {
</div>
)}
{/* ===== 能力默认模型(Auto 自动模式) ===== */}
{capabilities.length > 0 && (
<div style={{ background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', padding: '20px', marginTop: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '4px', flexWrap: 'wrap', gap: '8px' }}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>能力默认模型</h3>
<span style={{ fontSize: '12px', color: '#888' }}>
为每项能力指定默认模型,前台「Auto 自动」将按对话意图自动匹配
</span>
</div>
<p style={{ margin: '0 0 16px', fontSize: '13px', color: '#999' }}>
选择「Auto 自动」时,系统依据用户输入(如含「画图 / 视频」)自动选用对应能力的默认模型;文本类能力在未单独配置时回退到全局默认模型。
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }}>
{capabilities.map((cap: any) => {
const capModels = capabilityModels.filter(
(m: any) => (m.capabilities || []).includes(cap.id) || capabilityDefaults[cap.id] === m.id
);
const cur = capabilityDefaults[cap.id] || '';
const saving = savingCaps.has(cap.id);
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
return (
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<span style={{ width: '10px', height: '10px', borderRadius: '3px', background: dotColor, flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: '14px' }}>{cap.label}</span>
<span style={{ fontSize: '11px', color: '#bbb', marginLeft: 'auto' }}>{cap.id}</span>
</div>
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
<select
value={cur}
disabled={saving}
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
>
<option value="">— 未设置(回退全局默认)—</option>
{capModels.map((m: any) => (
<option key={m.id} value={m.id}>
{m.display_name || m.name}{m.provider_name ? `(${m.provider_name})` : ''}
</option>
))}
</select>
</div>
);
})}
</div>
</div>
)}
{/* Provider Modal */}
{showAddProvider && (
<div style={modalOverlay}>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { adminAPI } from '@/api';
interface Summary {
totals: { calls: number; tokens: number; cost: number; users: number };
by_model: { model_name: string; calls: number; tokens: number; cost: number }[];
by_user: { user_id: string; username: string; calls: number; tokens: number; cost: number }[];
daily: { date: string; tokens: number; cost: number; calls: number }[];
recent: { trace_id: string | null; model_name: string; tokens: number; cost: number; created_at: string | null }[];
}
const ACCENT = '#5B5FED';
const ACCENT_HOVER = '#4A4ECC';
export default function Observability() {
const [days, setDays] = useState(30);
const [data, setData] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
useEffect(() => {
let alive = true;
setLoading(true);
setErr('');
adminAPI.getObservabilitySummary(days)
.then((d: Summary) => { if (alive) setData(d); })
.catch((e: any) => { if (alive) setErr(e?.message || '加载失败'); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [days]);
const maxTokens = data ? Math.max(1, ...data.daily.map(d => d.tokens)) : 1;
const maxModel = data ? Math.max(1, ...data.by_model.map(m => m.tokens)) : 1;
const fmt = (n: number) => n.toLocaleString('zh-CN');
const fmtCost = (n: number) => `¥${n.toFixed(4)}`;
const card: React.CSSProperties = {
background: '#fff', border: '1px solid #eceef5', borderRadius: 16,
padding: 20, boxShadow: '0 1px 3px rgba(20,30,60,0.04)', marginBottom: 18,
};
const stat: React.CSSProperties = {
flex: 1, minWidth: 150, background: '#fff', border: '1px solid #eceef5',
borderRadius: 16, padding: 18, boxShadow: '0 1px 3px rgba(20,30,60,0.04)',
};
return (
<div style={{ padding: 24, maxWidth: 1160, margin: '0 auto', color: '#1f2330' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
<div>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>调用可观测</h2>
<div style={{ color: '#8a90a2', fontSize: 13, marginTop: 4 }}>
媒体 / LLM 调用归因 · 基于 trace_id 关联请求链路(Phase 2.2)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{[7, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
style={{
padding: '7px 14px', borderRadius: 10, cursor: 'pointer', fontSize: 13,
border: `1px solid ${days === d ? ACCENT : '#e2e5ee'}`,
background: days === d ? ACCENT : '#fff',
color: days === d ? '#fff' : '#5b6172', fontWeight: 600,
transition: 'all .15s',
}}>
{d} 天
</button>
))}
</div>
</div>
{err && <div style={{ ...card, borderColor: '#f3c2c2', color: '#c0392b' }}>{err}</div>}
{loading && !data && <div style={{ color: '#8a90a2' }}>加载中…</div>}
{data && (
<>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginBottom: 18 }}>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>调用次数</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.calls)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>Token 消耗</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.tokens)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>成本</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6, color: ACCENT }}>{fmtCost(data.totals.cost)}</div></div>
<div style={stat}><div style={{ color: '#8a90a2', fontSize: 12 }}>活跃用户</div><div style={{ fontSize: 24, fontWeight: 700, marginTop: 6 }}>{fmt(data.totals.users)}</div></div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按模型</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.by_model.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.by_model.map(m => (
<div key={m.model_name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 150, fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_name}</div>
<div style={{ flex: 1, height: 10, background: '#f1f3f9', borderRadius: 6, overflow: 'hidden' }}>
<div style={{ width: `${(m.tokens / maxModel) * 100}%`, height: '100%', background: ACCENT, borderRadius: 6 }} />
</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: '#5b6172' }}>{fmt(m.tokens)}</div>
<div style={{ width: 90, textAlign: 'right', fontSize: 13, color: ACCENT }}>{fmtCost(m.cost)}</div>
</div>
))}
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>每日趋势(Token)</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 140, overflowX: 'auto', paddingBottom: 4 }}>
{data.daily.length === 0 && <div style={{ color: '#8a90a2', fontSize: 13 }}>暂无数据</div>}
{data.daily.map(d => (
<div key={d.date} title={`${d.date} · ${fmt(d.tokens)} tokens · ${fmtCost(d.cost)}`}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 22 }}>
<div style={{ width: 16, height: `${(d.tokens / maxTokens) * 110}px`, background: ACCENT, borderRadius: '4px 4px 0 0', opacity: 0.85 }} />
<div style={{ fontSize: 9, color: '#aab', transform: 'rotate(-45deg)', whiteSpace: 'nowrap' }}>{d.date.slice(5)}</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<div style={{ ...card, flex: 1, minWidth: 320 }}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>按用户(Top 20)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>用户</th><th style={thR}>调用</th><th style={thR}>Token</th><th style={thR}>成本</th>
</tr></thead>
<tbody>
{data.by_user.map(u => (
<tr key={u.user_id} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={td}>{u.username}</td>
<td style={tdR}>{fmt(u.calls)}</td>
<td style={tdR}>{fmt(u.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(u.cost)}</td>
</tr>
))}
{data.by_user.length === 0 && <tr><td colSpan={4} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</div>
<div style={card}>
<h3 style={{ margin: '0 0 14px', fontSize: 16 }}>最近调用(trace_id 可下钻)</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={{ color: '#8a90a2', textAlign: 'left' }}>
<th style={th}>Trace ID</th><th style={th}>模型</th><th style={thR}>Token</th><th style={thR}>成本</th><th style={thR}>时间</th>
</tr></thead>
<tbody>
{data.recent.map((r, i) => (
<tr key={(r.trace_id || '') + i} style={{ borderTop: '1px solid #f1f3f9' }}>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.trace_id || '—'}</td>
<td style={td}>{r.model_name}</td>
<td style={tdR}>{fmt(r.tokens)}</td>
<td style={{ ...tdR, color: ACCENT }}>{fmtCost(r.cost)}</td>
<td style={tdR}>{r.created_at || '—'}</td>
</tr>
))}
{data.recent.length === 0 && <tr><td colSpan={5} style={{ ...td, color: '#8a90a2' }}>暂无数据</td></tr>}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
const th: React.CSSProperties = { padding: '8px 6px', fontWeight: 600, fontSize: 12 };
const thR: React.CSSProperties = { ...th, textAlign: 'right' };
const td: React.CSSProperties = { padding: '8px 6px' };
const tdR: React.CSSProperties = { ...td, textAlign: 'right', color: '#5b6172' };
+1 -1
View File
@@ -131,7 +131,7 @@ const Payment: React.FC = () => {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">支付渠道配置</h1>
<h1 className="admin-page-title">支付渠道</h1>
{Object.entries(msg).map(([id, m]) => m.text ? (
<div key={id} style={{
+4 -3
View File
@@ -3,6 +3,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import { formatBjTime } from '@/utils/datetime';
@@ -464,7 +465,7 @@ export default function PrivacySettings() {
const formatDate = (d: string | null) => d ? new Date(d).toLocaleString('zh-CN') : '-';
const formatDate = (d: string | null) => d ? formatBjTime(d) : '-';
@@ -1300,7 +1301,7 @@ export default function PrivacySettings() {
<div>
<h4 style={{ margin: '0 0 12px', color: '#ccc', fontSize: 14 }}>MCP 服务器配置</h4>
<h4 style={{ margin: '0 0 12px', color: '#ccc', fontSize: 14 }}>MCP Client 配置</h4>
<pre style={{
@@ -1370,7 +1371,7 @@ export default function PrivacySettings() {
<li>安装依赖: <code style={{ background: '#FFFFFF', padding: '2px 6px', borderRadius: 4, color: '#4ade80' }}>pip install -r requirements.txt</code></li>
<li>配置环境变量: 将上方 MCP 服务器配置添加到 Goose</li>
<li>配置环境变量: 将上方 MCP Client 配置添加到 Goose</li>
<li>启动 Goose: 配置将自动连接到平台</li>
+379 -454
View File
@@ -1,454 +1,379 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SiteSettings: React.FC = () => {
const [brand, setBrand] = useState<Record<string, string>>({
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
});
const [sms, setSms] = useState({
provider: '', api_key: '', api_secret: '', sign_name: '', template_code: '',
});
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<string | null>(null);
const [showEmailTest, setShowEmailTest] = useState(false);
const [emailTestTo, setEmailTestTo] = useState('');
const [emailTestSubject, setEmailTestSubject] = useState('');
const [emailTestBody, setEmailTestBody] = useState('');
const [emailGenerating, setEmailGenerating] = useState(false);
// AI生成测试邮件内容
const handleAIGenerateContent = async () => {
setEmailGenerating(true);
try {
const res = await adminAPI.generateEmailContent('测试邮件');
const data = (res as any).data || res;
setEmailTestSubject(data.subject || '');
setEmailTestBody(data.body || '');
} catch (e) {
// 降级:使用客户端预设内容
const subjects = [
'【测试】AIFlow邮件服务配置验证',
'AIFlow平台 - 邮件投递测试',
'测试邮件:您的SMTP服务已就绪',
];
const bodies = [
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
];
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
} finally {
setEmailGenerating(false);
}
};
// 加载真实配置
useEffect(() => {
async function loadConfig() {
try {
const [siteRes, emailRes, smsRes] = await Promise.all([
adminAPI.getSiteSettings(),
adminAPI.getEmailConfig(),
adminAPI.getSmsConfig(),
]);
if (siteRes?.data) {
setBrand({
logo: siteRes.data.logo || '',
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
announcement: siteRes.data.announcement || '',
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
});
}
if (emailRes?.data) {
setMail({
smtp_host: emailRes.data.smtp_host || '',
smtp_port: emailRes.data.smtp_port || 587,
username: emailRes.data.username || '',
password: emailRes.data.password || '',
from_address: emailRes.data.from_address || '',
use_tls: emailRes.data.use_tls !== false,
});
}
if (smsRes?.data) {
setSms({
provider: smsRes.data.provider || '',
api_key: smsRes.data.api_key || '',
api_secret: smsRes.data.api_secret || '',
sign_name: smsRes.data.sign_name || '',
template_code: smsRes.data.template_code || '',
});
}
} catch (e) {
console.error('加载配置失败:', e);
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
const showSaved = (block: string) => {
setSavedMsg(block + ' 保存成功');
setTimeout(() => setSavedMsg(null), 2000);
};
const showTestResult = (result: string) => {
setTestResult(result);
setTimeout(() => setTestResult(null), 5000);
};
// 保存品牌设置
const saveSiteSettings = async () => {
try {
await adminAPI.updateSiteSettings({
logo: brand.logo,
site_name: brand.siteName,
announcement: brand.announcement,
icp_number: brand.icpNumber,
contact_phone: brand.contactPhone,
});
showSaved('品牌设置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存邮件配置
const saveEmailConfig = async () => {
try {
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
showSaved('邮件配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存短信配置
const saveSmsConfig = async () => {
try {
await adminAPI.updateSmsConfig({
provider: sms.provider,
api_key: sms.api_key,
api_secret: sms.api_secret,
sign_name: sms.sign_name,
template_code: sms.template_code,
});
showSaved('短信配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 发送测试邮件 - 先保存配置再发送
const sendTestEmail = async () => {
if (!emailTestTo.trim()) {
showTestResult('请输入收件邮箱地址');
return;
}
try {
// 测试前先保存邮件配置到数据库,确保使用最新配置
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
const res = await adminAPI.testEmail({
test_to: emailTestTo.trim(),
test_subject: emailTestSubject || undefined,
test_body: emailTestBody || undefined,
});
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
setShowEmailTest(false);
} catch (e: any) {
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
}
};
const openEmailTest = () => {
setEmailTestTo('');
setEmailTestSubject('');
setEmailTestBody('');
setShowEmailTest(true);
};
// 发送测试短信
const sendTestSms = async () => {
try {
const res = await adminAPI.testSms();
showTestResult('测试短信发送成功: ' + (res?.message || 'OK'));
} catch (e: any) {
showTestResult('测试短信发送失败: ' + (e.detail || e.message));
}
};
if (loading) {
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
{savedMsg && (
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
{' '}{savedMsg}
</span>
)}
</div>
{/* Brand Settings */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></SVGIcon>
{' '}品牌设置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">LOGO URL</label>
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">网站名称</label>
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">首页公告</label>
<textarea
className="admin-form-input admin-form-textarea"
value={brand.announcement}
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
style={{ minHeight: 60 }}
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">备案号</label>
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">客服联系方式</label>
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
</div>
</div>
{/* Mail Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}邮件配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">SMTP服务器</label>
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">端口</label>
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名</label>
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码</label>
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
</div>
</div>
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
<label className="admin-form-label">发件地址</label>
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
</div>
</div>
{/* SMS Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></SVGIcon>
{' '}短信配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">短信服务商</label>
<select className="admin-form-input" value={sms.provider} onChange={e => setSms({ ...sms, provider: e.target.value })}>
<option value="">请选择</option>
<option value="阿里云短信">阿里云短信</option>
<option value="腾讯云短信">腾讯云短信</option>
<option value="华为云短信">华为云短信</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">短信签名</label>
<input className="admin-form-input" value={sms.sign_name} onChange={e => setSms({ ...sms, sign_name: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input className="admin-form-input" value={sms.api_key} onChange={e => setSms({ ...sms, api_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Secret</label>
<input className="admin-form-input" type="password" value={sms.api_secret} onChange={e => setSms({ ...sms, api_secret: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSmsConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={sendTestSms}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试短信
</button>
</div>
</div>
{/* Email Test Modal */}
{showEmailTest && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowEmailTest(false)}>
<div style={{
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}发送测试邮件
</h3>
<div className="admin-form-group">
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input
className="admin-form-input"
type="email"
value={emailTestTo}
onChange={e => setEmailTestTo(e.target.value)}
placeholder="例如: admin@example.com"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件主题</label>
<input
className="admin-form-input"
value={emailTestSubject}
onChange={e => setEmailTestSubject(e.target.value)}
placeholder="测试邮件标题"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件内容</label>
<textarea
className="admin-form-input admin-form-textarea"
value={emailTestBody}
onChange={e => setEmailTestBody(e.target.value)}
style={{ minHeight: 120 }}
placeholder="测试邮件内容..."
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={handleAIGenerateContent}
disabled={emailGenerating}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
</button>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={sendTestEmail}
disabled={!emailTestTo.trim()}
>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
取消
</button>
</div>
</div>
</div>
)}
{/* Test Result Toast */}
{testResult && (
<div style={{
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 9999, fontSize: 'var(--text-sm)',
}}>
{testResult}
</div>
)}
</div>
);
};
export default SiteSettings;
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SiteSettings: React.FC = () => {
const [brand, setBrand] = useState<Record<string, string>>({
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
});
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<string | null>(null);
const [showEmailTest, setShowEmailTest] = useState(false);
const [emailTestTo, setEmailTestTo] = useState('');
const [emailTestSubject, setEmailTestSubject] = useState('');
const [emailTestBody, setEmailTestBody] = useState('');
const [emailGenerating, setEmailGenerating] = useState(false);
// AI生成测试邮件内容
const handleAIGenerateContent = async () => {
setEmailGenerating(true);
try {
const res = await adminAPI.generateEmailContent('测试邮件');
const data = (res as any).data || res;
setEmailTestSubject(data.subject || '');
setEmailTestBody(data.body || '');
} catch (e) {
// 降级:使用客户端预设内容
const subjects = [
'【测试】AIFlow邮件服务配置验证',
'AIFlow平台 - 邮件投递测试',
'测试邮件:您的SMTP服务已就绪',
];
const bodies = [
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
];
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
} finally {
setEmailGenerating(false);
}
};
// 加载真实配置
useEffect(() => {
async function loadConfig() {
try {
const [siteRes, emailRes, smsRes] = await Promise.all([
adminAPI.getSiteSettings(),
adminAPI.getEmailConfig(),
adminAPI.getSmsConfig(),
]);
if (siteRes?.data) {
setBrand({
logo: siteRes.data.logo || '',
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
announcement: siteRes.data.announcement || '',
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
});
}
if (emailRes?.data) {
setMail({
smtp_host: emailRes.data.smtp_host || '',
smtp_port: emailRes.data.smtp_port || 587,
username: emailRes.data.username || '',
password: emailRes.data.password || '',
from_address: emailRes.data.from_address || '',
use_tls: emailRes.data.use_tls !== false,
});
}
if (smsRes?.data) {
setSms({
provider: smsRes.data.provider || '',
api_key: smsRes.data.api_key || '',
api_secret: smsRes.data.api_secret || '',
sign_name: smsRes.data.sign_name || '',
template_code: smsRes.data.template_code || '',
});
}
} catch (e) {
console.error('加载配置失败:', e);
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
const showSaved = (block: string) => {
setSavedMsg(block + ' 保存成功');
setTimeout(() => setSavedMsg(null), 2000);
};
const showTestResult = (result: string) => {
setTestResult(result);
setTimeout(() => setTestResult(null), 5000);
};
// 保存品牌设置
const saveSiteSettings = async () => {
try {
await adminAPI.updateSiteSettings({
logo: brand.logo,
site_name: brand.siteName,
announcement: brand.announcement,
icp_number: brand.icpNumber,
contact_phone: brand.contactPhone,
});
showSaved('品牌设置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存邮件配置
const saveEmailConfig = async () => {
try {
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
showSaved('邮件配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存短信配置
// 发送测试邮件 - 先保存配置再发送
const sendTestEmail = async () => {
if (!emailTestTo.trim()) {
showTestResult('请输入收件邮箱地址');
return;
}
try {
// 测试前先保存邮件配置到数据库,确保使用最新配置
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
const res = await adminAPI.testEmail({
test_to: emailTestTo.trim(),
test_subject: emailTestSubject || undefined,
test_body: emailTestBody || undefined,
});
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
setShowEmailTest(false);
} catch (e: any) {
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
}
};
const openEmailTest = () => {
setEmailTestTo('');
setEmailTestSubject('');
setEmailTestBody('');
setShowEmailTest(true);
};
if (loading) {
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
{savedMsg && (
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
{' '}{savedMsg}
</span>
)}
</div>
{/* Brand Settings */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></SVGIcon>
{' '}品牌设置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">LOGO URL</label>
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">网站名称</label>
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">首页公告</label>
<textarea
className="admin-form-input admin-form-textarea"
value={brand.announcement}
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
style={{ minHeight: 60 }}
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">备案号</label>
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">客服联系方式</label>
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
</div>
</div>
{/* Mail Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}邮件配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">SMTP服务器</label>
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">端口</label>
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名</label>
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码</label>
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
</div>
</div>
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
<label className="admin-form-label">发件地址</label>
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
</div>
</div>
{/* SMS Config */}
{/* Email Test Modal */}
{showEmailTest && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowEmailTest(false)}>
<div style={{
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}发送测试邮件
</h3>
<div className="admin-form-group">
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input
className="admin-form-input"
type="email"
value={emailTestTo}
onChange={e => setEmailTestTo(e.target.value)}
placeholder="例如: admin@example.com"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件主题</label>
<input
className="admin-form-input"
value={emailTestSubject}
onChange={e => setEmailTestSubject(e.target.value)}
placeholder="测试邮件标题"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件内容</label>
<textarea
className="admin-form-input admin-form-textarea"
value={emailTestBody}
onChange={e => setEmailTestBody(e.target.value)}
style={{ minHeight: 120 }}
placeholder="测试邮件内容..."
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={handleAIGenerateContent}
disabled={emailGenerating}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
</button>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={sendTestEmail}
disabled={!emailTestTo.trim()}
>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
取消
</button>
</div>
</div>
</div>
)}
{/* Test Result Toast */}
{testResult && (
<div style={{
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 9999, fontSize: 'var(--text-sm)',
}}>
{testResult}
</div>
)}
</div>
);
};
export default SiteSettings;
File diff suppressed because it is too large. Load diff
+252
View File
@@ -0,0 +1,252 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SmsConfig: React.FC = () => {
const [cfg, setCfg] = useState<Record<string, string>>({
provider: '',
api_key: '',
api_secret: '',
sign_name: '',
template_code: '',
username: '',
password: '',
goods_id: '',
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [querying, setQuerying] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [balance, setBalance] = useState<{ sent: number; remain: number } | null>(null);
useEffect(() => {
(async () => {
try {
const res = await adminAPI.getSmsConfig();
const data = (res as any)?.data ?? res;
setCfg({
provider: data.provider || '',
api_key: data.api_key || '',
api_secret: data.api_secret || '',
sign_name: data.sign_name || '',
template_code: data.template_code || '',
username: data.username || '',
password: data.password || '',
goods_id: data.goods_id || '',
});
} catch (e) {
// ignore
} finally {
setLoading(false);
}
})();
}, []);
const update = (patch: Partial<Record<string, string>>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const save = async () => {
setSaving(true);
setSavedMsg(null);
setTestResult(null);
try {
await adminAPI.updateSmsConfig({
provider: cfg.provider,
api_key: cfg.api_key,
api_secret: cfg.api_secret,
sign_name: cfg.sign_name,
template_code: cfg.template_code,
username: cfg.username,
password: cfg.password,
goods_id: cfg.goods_id,
});
setSavedMsg('短信配置已保存');
} catch (e: any) {
setSavedMsg('保存失败:' + (e?.response?.data?.detail || e?.message || '未知错误'));
} finally {
setSaving(false);
}
};
const sendTest = async () => {
setTesting(true);
setTestResult(null);
setSavedMsg(null);
try {
const res = await adminAPI.testSms();
const data = (res as any)?.data ?? res;
setTestResult({ ok: true, msg: data?.message || '测试短信已发送' });
} catch (e: any) {
setTestResult({ ok: false, msg: e?.response?.data?.detail || e?.message || '发送失败' });
} finally {
setTesting(false);
}
};
const queryBalance = async () => {
setQuerying(true);
setBalance(null);
setTestResult(null);
try {
const res = await adminAPI.getSmsBalance();
const data = (res as any)?.data ?? res;
setBalance({ sent: data?.sent ?? 0, remain: data?.remain ?? 0 });
} catch (e: any) {
setTestResult({ ok: false, msg: e?.response?.data?.detail || e?.message || '查询余额失败' });
} finally {
setQuerying(false);
}
};
const isSmsBao = cfg.provider === '短信宝';
if (loading) {
return <div className="admin-page"><div className="admin-page-title">短信配置</div><p style={{ color: 'var(--color-text-secondary)' }}>加载中…</p></div>;
}
return (
<div className="admin-page">
<div className="admin-page-header">
<div>
<h2 className="admin-page-title">短信配置</h2>
<p className="admin-page-subtitle">配置短信服务商,用于验证码、通知类短信的发送</p>
</div>
</div>
{savedMsg && (
<div className={`admin-alert ${savedMsg.startsWith('保存失败') ? 'admin-alert-error' : 'admin-alert-success'}`}>
{savedMsg}
</div>
)}
{testResult && (
<div className={`admin-alert ${testResult.ok ? 'admin-alert-success' : 'admin-alert-error'}`}>
{testResult.msg}
</div>
)}
{balance && (
<div className="admin-alert admin-alert-info">
短信宝账户:已发送 <b>{balance.sent}</b> 条,剩余 <b>{balance.remain}</b> 条
</div>
)}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}>
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</SVGIcon>
{' '}服务商与账号
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">短信服务商</label>
<select className="admin-form-input" value={cfg.provider} onChange={(e) => update({ provider: e.target.value })}>
<option value="">请选择</option>
<option value="阿里云短信">阿里云短信</option>
<option value="腾讯云短信">腾讯云短信</option>
<option value="华为云短信">华为云短信</option>
<option value="短信宝">短信宝</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">短信签名</label>
<input
className="admin-form-input"
placeholder={isSmsBao ? '如:七虎AI(发送时自动包裹【】)' : '如:七虎AI'}
value={cfg.sign_name}
onChange={(e) => update({ sign_name: e.target.value })}
/>
</div>
</div>
{isSmsBao ? (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名 (u)</label>
<input className="admin-form-input" value={cfg.username} onChange={(e) => update({ username: e.target.value })} placeholder="短信宝登录用户名" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码 (p)</label>
<input className="admin-form-input" type="password" value={cfg.password} onChange={(e) => update({ password: e.target.value })} placeholder="短信宝登录密码(发送时自动 MD5)" />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group" style={{ maxWidth: 360 }}>
<label className="admin-form-label">专属通道产品 ID (g) <span style={{ color: 'var(--color-text-muted)' }}>选填</span></label>
<input className="admin-form-input" value={cfg.goods_id} onChange={(e) => update({ goods_id: e.target.value })} placeholder="留空使用默认通用产品" />
</div>
</div>
<div className="admin-form-hint">
短信宝发送接口:<code>api.smsbao.com/sms</code>,密码以登录密码的 MD5(32位) 提交,短信内容需包含签名 <code>【签名】</code>。
</div>
</>
) : (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input className="admin-form-input" value={cfg.api_key} onChange={(e) => update({ api_key: e.target.value })} placeholder={cfg.provider === '阿里云短信' ? 'AccessKey ID' : 'API Key'} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Secret</label>
<input className="admin-form-input" type="password" value={cfg.api_secret} onChange={(e) => update({ api_secret: e.target.value })} placeholder={cfg.provider === '阿里云短信' ? 'AccessKey Secret' : 'API Secret'} />
</div>
</div>
{cfg.provider === '阿里云短信' && (
<div className="admin-form-row">
<div className="admin-form-group" style={{ maxWidth: 360 }}>
<label className="admin-form-label">模板 Code <span style={{ color: 'var(--color-text-muted)' }}>选填</span></label>
<input className="admin-form-input" value={cfg.template_code} onChange={(e) => update({ template_code: e.target.value })} placeholder="如 SMS_00000001" />
</div>
</div>
)}
</>
)}
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={save} disabled={saving}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" /><polyline points="17 21 17 13 7 13 7 21" /><polyline points="7 3 7 8 15 8" /></SVGIcon>
{saving ? '保存中…' : '保存设置'}
</button>
<button className="admin-btn admin-btn-outline" onClick={sendTest} disabled={testing}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 2 2" /></SVGIcon>
{testing ? '发送中…' : '发送测试短信'}
</button>
{isSmsBao && (
<button className="admin-btn admin-btn-outline" onClick={queryBalance} disabled={querying}>
<SVGIcon><line x1="12" y1="1" x2="12" y2="23" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></SVGIcon>
{querying ? '查询中…' : '查询余额'}
</button>
)}
</div>
</div>
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></SVGIcon>
{' '}说明
</h3>
</div>
<ul className="admin-tips">
<li>测试短信将发送至<strong>当前管理员账号绑定的手机号</strong>,请先在用户管理中完善手机号。</li>
<li>阿里云、腾讯云、华为云使用平台统一的短信网关;短信宝为独立通道,支持余额查询。</li>
<li>短信内容须符合运营商规范,包含签名与退订提示,避免触发敏感词拦截。</li>
</ul>
</div>
</div>
);
};
export default SmsConfig;
+62 -62
View File
@@ -358,7 +358,7 @@ const Storage: React.FC = () => {
} catch (e: any) {
showToast('Load failed: ' + (e.message || e), 'error');
showToast('加载失败:' + (e.message || e), 'error');
} finally {
@@ -412,7 +412,7 @@ const Storage: React.FC = () => {
setModalVisible(true);
} catch (e: any) { showToast('Failed to get config detail', 'error'); }
} catch (e: any) { showToast('获取配置详情失败', 'error'); }
};
@@ -428,7 +428,7 @@ const Storage: React.FC = () => {
if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) {
showToast('Access Key and Secret Key required', 'error'); return;
showToast('请填写 Access Key 和 Secret Key', 'error'); return;
}
@@ -442,7 +442,7 @@ const Storage: React.FC = () => {
try { configObj = JSON.parse(formData.config_str); }
catch { showToast('Invalid JSON config', 'error'); setSubmitting(false); return; }
catch { showToast('JSON 配置格式无效', 'error'); setSubmitting(false); return; }
}
@@ -460,13 +460,13 @@ const Storage: React.FC = () => {
if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim();
if (editingConfig) { await adminAPI.updateAdminStorageConfig(editingConfig.id, data); showToast('Updated'); }
if (editingConfig) { await adminAPI.updateAdminStorageConfig(editingConfig.id, data); showToast('已更新'); }
else { await adminAPI.createAdminStorageConfig(data); showToast('Created'); }
else { await adminAPI.createAdminStorageConfig(data); showToast('已创建'); }
setModalVisible(false); loadConfigs(); loadStats();
} catch (e: any) { showToast('Failed: ' + (e.message || e), 'error'); }
} catch (e: any) { showToast('失败:' + (e.message || e), 'error'); }
finally { setSubmitting(false); }
@@ -476,7 +476,7 @@ const Storage: React.FC = () => {
const handleDelete = async (config: OSSConfig) => {
if (!window.confirm(`Delete "${config.name}"?`)) return;
if (!window.confirm(`确认删除"${config.name}"吗?`)) return;
try { await adminAPI.deleteAdminStorageConfig(config.id); showToast('已删除'); loadConfigs(); loadStats(); }
@@ -490,7 +490,7 @@ const Storage: React.FC = () => {
try { await adminAPI.toggleAdminStorageConfig(config.id); showToast(config.is_enabled ? '已禁用' : '已启用'); loadConfigs(); loadStats(); }
catch (e: any) { showToast('Failed', 'error'); }
catch (e: any) { showToast('失败', 'error'); }
};
@@ -500,9 +500,9 @@ const Storage: React.FC = () => {
if (config.is_default) return;
try { await adminAPI.setDefaultAdminStorageConfig(config.id); showToast(`Set "${config.name}" as default`); loadConfigs(); loadStats(); }
try { await adminAPI.setDefaultAdminStorageConfig(config.id); showToast(`已将"${config.name}"设为默认`); loadConfigs(); loadStats(); }
catch (e: any) { showToast('Failed', 'error'); }
catch (e: any) { showToast('失败', 'error'); }
};
@@ -518,7 +518,7 @@ const Storage: React.FC = () => {
const data = result.data;
showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error');
showToast(data.success ? '连接成功' : '连接失败', data.success ? 'success' : 'error');
loadConfigs(); loadStats();
@@ -570,7 +570,7 @@ const Storage: React.FC = () => {
setQuotaTotal(data.total || 0);
} catch (e: any) { showToast('Load failed', 'error'); }
} catch (e: any) { showToast('加载失败', 'error'); }
finally { setQuotaLoading(false); }
@@ -592,11 +592,11 @@ const Storage: React.FC = () => {
const result = await adminAPI.initAllStorageQuotas() as any;
showToast(result.message || 'Init complete');
showToast(result.message || '初始化完成');
loadQuotaOverview(); loadQuotaUsers();
} catch (e: any) { showToast('Init failed', 'error'); }
} catch (e: any) { showToast('初始化失败', 'error'); }
};
@@ -612,7 +612,7 @@ const Storage: React.FC = () => {
loadQuotaUsers();
} catch (e: any) { showToast('Recalc failed', 'error'); }
} catch (e: any) { showToast('重新计算失败', 'error'); }
};
@@ -626,7 +626,7 @@ const Storage: React.FC = () => {
setQuotaDetail(result.data || null);
} catch (e: any) { showToast('Failed to load detail', 'error'); }
} catch (e: any) { showToast('加载详情失败', 'error'); }
};
@@ -662,7 +662,7 @@ const Storage: React.FC = () => {
const numVal = parseFloat(adjustValue);
if (isNaN(numVal) || numVal < 0) { showToast('Invalid value', 'error'); return; }
if (isNaN(numVal) || numVal < 0) { showToast('无效的数值', 'error'); return; }
const bytes = adjustUnit === 'GB' ? Math.round(numVal * 1024 * 1024 * 1024) : Math.round(numVal * 1024 * 1024);
@@ -672,13 +672,13 @@ const Storage: React.FC = () => {
await adminAPI.adjustStorageQuota(adjustModal.user_id, { quota_bytes: bytes, quota_type: 'custom' });
showToast('Quota adjusted');
showToast('配额已调整');
setAdjustModal(null);
loadQuotaUsers(); loadQuotaOverview();
} catch (e: any) { showToast('Adjust failed', 'error'); }
} catch (e: any) { showToast('调整失败', 'error'); }
finally { setAdjusting(false); }
@@ -702,7 +702,7 @@ const Storage: React.FC = () => {
{activeTab === 'oss' && (
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ Add Storage</button>
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 新增存储</button>
)}
@@ -846,7 +846,7 @@ const Storage: React.FC = () => {
<select value={filterProvider} onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }}>
<option value="">全部类型</option>
@@ -858,7 +858,7 @@ const Storage: React.FC = () => {
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
</div>
@@ -906,7 +906,7 @@ const Storage: React.FC = () => {
<tr key={cfg.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#F8F9FC' }}>{cfg.name}</td>
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#1F2937' }}>{cfg.name}</td>
<td style={{ padding: '12px 16px' }}>
@@ -956,7 +956,7 @@ const Storage: React.FC = () => {
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: cfg.is_enabled ? '#EF4444' : '#10B981' }}>
{cfg.is_enabled ? 'Disable' : 'Enable'}
{cfg.is_enabled ? '禁用' : '启用'}
</button>
@@ -988,13 +988,13 @@ const Storage: React.FC = () => {
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#F8F9FC' }}>上一页</button>
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#1F2937' }}>上一页</button>
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>Page {page} / {Math.ceil(total / PAGE_SIZE)} ({total})</span>
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 ({total})</span>
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#F8F9FC' }}>下一页</button>
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#1F2937' }}>下一页</button>
</div>
@@ -1078,7 +1078,7 @@ const Storage: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#F8F9FC' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1F2937' }}>
{(quotaOverview.tier_labels || {})[level] || `Level ${level}`}
@@ -1086,7 +1086,7 @@ const Storage: React.FC = () => {
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>
Default
默认
</span>
@@ -1096,7 +1096,7 @@ const Storage: React.FC = () => {
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
{(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} users | Used: ${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : 'No users'}
{(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} 个用户 | 已用:${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : '暂无用户'}
</div>
@@ -1118,7 +1118,7 @@ const Storage: React.FC = () => {
<select value={quotaLevelFilter} onChange={(e) => { setQuotaLevelFilter(e.target.value === '' ? '' : parseInt(e.target.value)); setQuotaPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }}>
<option value="">全部等级</option>
@@ -1134,7 +1134,7 @@ const Storage: React.FC = () => {
onChange={(e) => { setQuotaKeyword(e.target.value); setQuotaPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
</div>
@@ -1184,7 +1184,7 @@ const Storage: React.FC = () => {
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500, color: '#F8F9FC' }}>{u.username}</div>
<div style={{ fontWeight: 500, color: '#1F2937' }}>{u.username}</div>
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>{u.email || '-'}</div>
@@ -1246,7 +1246,7 @@ const Storage: React.FC = () => {
color: u.quota_type === 'custom' ? '#F59E0B' : '#6C63FF',
}}>{u.quota_type === 'custom' ? 'Custom' : 'Plan'}</span>
}}>{u.quota_type === 'custom' ? '自定义' : '套餐'}</span>
</td>
@@ -1284,13 +1284,13 @@ const Storage: React.FC = () => {
<button disabled={quotaPage <= 1} onClick={() => setQuotaPage(p => p - 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage <= 1 ? 'not-allowed' : 'pointer', opacity: quotaPage <= 1 ? 0.5 : 1, color: '#F8F9FC' }}>上一页</button>
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage <= 1 ? 'not-allowed' : 'pointer', opacity: quotaPage <= 1 ? 0.5 : 1, color: '#1F2937' }}>上一页</button>
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} 页 ({quotaTotal})</span>
<button disabled={quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE)} onClick={() => setQuotaPage(p => p + 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 0.5 : 1, color: '#F8F9FC' }}>下一页</button>
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 0.5 : 1, color: '#1F2937' }}>下一页</button>
</div>
@@ -1330,7 +1330,7 @@ const Storage: React.FC = () => {
}}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#1F2937' }}>
{editingConfig ? '编辑存储配置' : '新建存储配置'}
@@ -1346,7 +1346,7 @@ const Storage: React.FC = () => {
placeholder="如:生产环境 MinIO"
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1356,7 +1356,7 @@ const Storage: React.FC = () => {
<select value={formData.provider} onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }}>
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }}>
<option value="minio">MinIO</option>
@@ -1376,7 +1376,7 @@ const Storage: React.FC = () => {
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : 'https://oss-cn-hangzhou.aliyuncs.com'}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1388,9 +1388,9 @@ const Storage: React.FC = () => {
<input type="text" value={formData.access_key} onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Access Key'}
placeholder={editingConfig ? '留空则保持不变' : '请输入 Access Key'}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1400,9 +1400,9 @@ const Storage: React.FC = () => {
<input type="password" value={formData.secret_key} onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Secret Key'}
placeholder={editingConfig ? '留空则保持不变' : '请输入 Secret Key'}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1418,7 +1418,7 @@ const Storage: React.FC = () => {
placeholder="存储桶名称"
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1430,7 +1430,7 @@ const Storage: React.FC = () => {
placeholder="区域,如 cn-north-1"
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1444,7 +1444,7 @@ const Storage: React.FC = () => {
placeholder="上传路径,如 aipaaS/uploads/"
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
@@ -1452,13 +1452,13 @@ const Storage: React.FC = () => {
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> Set as default
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> 设为默认
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
<input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> Enabled
<input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用
</label>
@@ -1466,13 +1466,13 @@ const Storage: React.FC = () => {
<div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Extra Config (JSON, optional)</label>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>额外配置 (JSON,可选)</label>
<textarea value={formData.config_str} onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
placeholder='{"use_ssl": true, "timeout": 30}' rows={3}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} />
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} />
</div>
@@ -1528,7 +1528,7 @@ const Storage: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, fontSize: '18px', color: '#F8F9FC' }}>存储详情</h3>
<h3 style={{ margin: 0, fontSize: '18px', color: '#1F2937' }}>存储详情</h3>
<button onClick={() => setQuotaDetail(null)} style={{ background: 'transparent', border: 'none', color: 'rgba(0,0,0,0.5)', cursor: 'pointer', fontSize: '20px' }}>x</button>
@@ -1538,7 +1538,7 @@ const Storage: React.FC = () => {
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#F8F9FC' }}>{quotaDetail.user.username}</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#1F2937' }}>{quotaDetail.user.username}</div>
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.4)' }}>{quotaDetail.user.email || '-'}</div>
@@ -1558,7 +1558,7 @@ const Storage: React.FC = () => {
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)' }}>使用量</span>
<span style={{ fontSize: '13px', color: '#fff', fontFamily: 'monospace' }}>
<span style={{ fontSize: '13px', color: '#1F2937', fontFamily: 'monospace' }}>
{formatBytes(quotaDetail.quota.used_bytes)} / {formatBytes(quotaDetail.quota.quota_bytes)}
@@ -1598,7 +1598,7 @@ const Storage: React.FC = () => {
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)', marginBottom: '4px' }}>文件数</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#fff' }}>{quotaDetail.file_count}</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#1F2937' }}>{quotaDetail.file_count}</div>
</div>
@@ -1618,7 +1618,7 @@ const Storage: React.FC = () => {
<div>
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', marginBottom: '8px', fontWeight: 600 }}>By File Type</div>
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', marginBottom: '8px', fontWeight: 600 }}>按文件类型</div>
{Object.entries(quotaDetail.file_type_stats).map(([ftype, stat]) => (
@@ -1626,7 +1626,7 @@ const Storage: React.FC = () => {
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.7)' }}>{ftype}</span>
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontFamily: 'monospace' }}>{stat.count} files / {formatBytes(stat.total_bytes)}</span>
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontFamily: 'monospace' }}>{stat.count} 个文件 / {formatBytes(stat.total_bytes)}</span>
</div>
@@ -1680,11 +1680,11 @@ const Storage: React.FC = () => {
}}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}>调整配额</h3>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#1F2937' }}>调整配额</h3>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#fff' }}>{adjustModal.username}</div>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1F2937' }}>{adjustModal.username}</div>
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
@@ -1704,11 +1704,11 @@ const Storage: React.FC = () => {
step="0.1" min="0"
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }} />
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }} />
<select value={adjustUnit} onChange={(e) => setAdjustUnit(e.target.value as 'GB' | 'MB')}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}>
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }}>
<option value="GB">GB</option>
+2 -1
View File
@@ -3,6 +3,7 @@ import { adminAPI } from '@/api';
import { mockUsers, mockMembershipPlans } from '@/mock/data';
import type { User, RegisterSettings } from '@/types';
import './Users.css';
import { formatBjTime } from '@/utils/datetime';
// ===== SVG Icons =====
const IconSearch = () => (
@@ -141,7 +142,7 @@ const UserDetailModal: React.FC<UserDetailModalProps> = ({ user, onClose }) => {
</div>
<div className="user-detail-item">
<span className="detail-label">注册时间</span>
<span className="detail-value">{new Date(user.createdAt).toLocaleString('zh-CN')}</span>
<span className="detail-value">{formatBjTime(user.createdAt)}</span>
</div>
</div>
</div>
+131
View File
@@ -0,0 +1,131 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { imUserAPI } from '@/api';
const PRIMARY = '#5B5FED';
const AppServices: React.FC = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string>('');
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await imUserAPI.appServices({ category: category || undefined, search: search || undefined, page, page_size: 12 });
if (res?.success) {
setItems(res.data.items || []);
setTotal(res.data.total || 0);
} else {
showToast('加载应用服务失败', 'error');
}
} catch (e) {
showToast('加载应用服务失败', 'error');
} finally {
setLoading(false);
}
}, [category, search, page]);
useEffect(() => { load(); }, [load]);
const categories = [
{ key: '', name: '全部' },
{ key: 'customer_service', name: '客服助手' },
{ key: 'data_analysis', name: '数据分析' },
{ key: 'coding', name: '编程辅助' },
{ key: 'writing', name: '内容创作' },
{ key: 'education', name: '教育培训' },
{ key: 'marketing', name: '营销推广' },
{ key: 'hr', name: '人力资源' },
{ key: 'finance', name: '财务法务' },
];
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '1080px', margin: '0 auto' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用服务</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
浏览平台已上架的 AI 应用服务,按需接入你的工作流。由七虎AI 大脑统一驱动,覆盖客服、分析、创作等多类场景。
</p>
</div>
{toast && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999, padding: '12px 24px', borderRadius: '8px', background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 搜索 + 分类筛选 */}
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center', marginBottom: '20px' }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索应用名称 / 描述…"
style={{ flex: '1 1 260px', minWidth: '200px', padding: '9px 14px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }}
/>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{categories.map((c) => (
<button
key={c.key}
onClick={() => { setCategory(c.key); setPage(1); }}
style={{
padding: '7px 14px', borderRadius: '20px', fontSize: '13px', cursor: 'pointer',
border: '1px solid', borderColor: category === c.key ? PRIMARY : '#E5E7EB',
background: category === c.key ? PRIMARY : '#fff', color: category === c.key ? '#fff' : '#6B7280',
}}
>{c.name}</button>
))}
</div>
</div>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: '50px', textAlign: 'center', color: '#9CA3AF', background: '#F8F9FC', borderRadius: '12px' }}>暂无已发布的应用服务</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{items.map((app) => (
<div key={app.id} style={{ border: '1px solid #E5E7EB', borderRadius: '14px', padding: '20px', background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{ width: '44px', height: '44px', borderRadius: '10px', background: PRIMARY + '18', color: PRIMARY, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px' }}>{app.icon ? <img src={app.icon} alt="" style={{ width: 28, height: 28, borderRadius: 6 }} /> : '🤖'}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1A1F36' }}>{app.name}</div>
<div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}>
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#EEF0FF', color: PRIMARY }}>{app.type_name}</span>
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#F1F5F9', color: '#64748B' }}>{app.category_name}</span>
</div>
</div>
</div>
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, flex: 1, margin: '0 0 14px 0' }}>{app.description || '暂无描述'}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>{app.published_at ? '上架于 ' + new Date(app.published_at).toLocaleDateString('zh-CN') : ''}</span>
<button onClick={() => showToast('已加入「我的应用」,可在 AI 聊天中调用')} style={{ padding: '7px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '13px', fontWeight: 500, cursor: 'pointer' }}>使用</button>
</div>
</div>
))}
</div>
)}
{total > 12 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '24px', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn, opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: '13px', color: '#6B7280' }}>第 {page} 页 / 共 {Math.ceil(total / 12)} 页({total} 个)</span>
<button disabled={page >= Math.ceil(total / 12)} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn, opacity: page >= Math.ceil(total / 12) ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
</div>
);
};
const pageBtn: React.CSSProperties = { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', cursor: 'pointer' };
export default AppServices;
+2 -2
View File
@@ -207,7 +207,7 @@
}
.asset-novel-card:hover {
border-color: #F59E0B;
border-color: #E8922C;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
@@ -647,7 +647,7 @@
gap: 4px;
padding: 2px 6px;
background: #EEEDFF;
color: #6C63FF;
color: #5B5FED;
border-radius: var(--radius-xs);
font-size: 11px;
font-weight: 600;
+45 -29
View File
@@ -6,7 +6,7 @@ import './AssetsPage.css';
const fileTypeLabels: Record<string, { label: string; bg: string; color: string }> = {
word: { label: 'Word', bg: '#E8F1FF', color: '#2563EB' },
excel: { label: 'Excel', bg: '#E8F8EE', color: '#059669' },
excel: { label: 'Excel', bg: '#E8F8EE', color: '#219B5E' },
ppt: { label: 'PPT', bg: '#FEF2E2', color: '#D97706' },
pdf: { label: 'PDF', bg: '#FDEBEB', color: '#DC2626' },
txt: { label: 'TXT', bg: '#F1F3F7', color: '#64748B' },
@@ -54,6 +54,7 @@ function mapFileTypeToLabel(ft: string): string {
const mapping: Record<string, string> = {
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
word: 'word', excel: 'excel',
};
return mapping[ft] || 'other';
}
@@ -392,24 +393,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 = mapFileTypeToLabel(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;
@@ -424,10 +444,10 @@ const AssetsPage: React.FC = () => {
const DEFAULT_CATEGORIES: AssetCategoryDef[] = [
{ id: 'file', name: '文件', type: 'file', color: '#5B5FED', icon: 'file', isDefault: true },
{ id: 'image', name: '图片', type: 'image', color: '#10B981', icon: 'image', isDefault: true },
{ id: 'video', name: '视频', type: 'video', color: '#EF4444', icon: 'video', isDefault: true },
{ id: 'image', name: '图片', type: 'image', color: '#219B5E', icon: 'image', isDefault: true },
{ id: 'video', name: '视频', type: 'video', color: '#D9383A', icon: 'video', isDefault: true },
{ id: 'audio', name: '音频', type: 'audio', color: '#8B5CF6', icon: 'audio', isDefault: true },
{ id: 'novel', name: '小说', type: 'novel', color: '#F59E0B', icon: 'novel', isDefault: true },
{ id: 'novel', name: '小说', type: 'novel', color: '#E8922C', icon: 'novel', isDefault: true },
{ id: 'other', name: '其他', type: 'other', color: '#64748B', icon: 'other', isDefault: true },
{ id: 'custom', name: '自定义', type: 'custom', color: '#EC4899', icon: 'custom', isDefault: true },
];
@@ -451,13 +471,9 @@ const AssetsPage: React.FC = () => {
useEffect(() => {
fetchCategories();
fetchAssets();
fetchAllAssets();
}, []);
useEffect(() => {
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
}, [activeCategory]);
// Filter by sub-category for files
const filtered = (() => {
// 如果是文件分类,先确保只显示文件类资产
@@ -521,11 +537,11 @@ const AssetsPage: React.FC = () => {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
文件
</button>
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#10B981', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#219B5E', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
图片
</button>
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#EF4444', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#D9383A', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
视频
</button>
@@ -533,7 +549,7 @@ const AssetsPage: React.FC = () => {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
音频
</button>
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#F59E0B', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#E8922C', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
小说
</button>
@@ -576,7 +592,7 @@ const AssetsPage: React.FC = () => {
return (
<div key={asset.id} className="asset-novel-card" onClick={() => setSelectedAsset(asset)}>
<div className="novel-card-cover">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#F59E0B', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#E8922C', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
<span className="novel-card-badge">小说</span>
</div>
<div className="novel-card-info">
@@ -603,10 +619,10 @@ const AssetsPage: React.FC = () => {
</span>
)}
{asset.category === 'image' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#219B5E" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
)}
{asset.category === 'video' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#D9383A" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
)}
{asset.category === 'audio' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
@@ -640,7 +656,7 @@ const AssetsPage: React.FC = () => {
{showCreate && (
<CreateAssetModal
onClose={() => setShowCreate(false)}
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
onCreated={() => fetchAllAssets()}
/>
)}
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+491
View File
@@ -446,6 +446,113 @@
line-height: 1.6;
}
.md-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: var(--text-sm);
background: #fff;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.md-table thead {
background: linear-gradient(135deg, #5B5FED 0%, #7B7FEE 100%);
color: #fff;
}
.md-table th {
padding: 10px 14px;
font-weight: 600;
text-align: left;
font-size: var(--text-xs);
letter-spacing: 0.3px;
}
.md-table td {
padding: 10px 14px;
border-bottom: 1px solid #EEF0F4;
color: var(--color-text-primary);
vertical-align: top;
}
.md-table tbody tr:last-child td {
border-bottom: none;
}
.md-table tbody tr:hover {
background: #F8F9FC;
}
.md-table tbody tr:nth-child(even) {
background: #FAFBFD;
}
.md-table tbody tr:nth-child(even):hover {
background: #F0F2F7;
}
.message-bubble.user .md-table {
background: rgba(255, 255, 255, 0.08);
color: inherit;
}
.message-bubble.user .md-table th,
.message-bubble.user .md-table td {
color: inherit;
border-color: rgba(255, 255, 255, 0.15);
}
.message-bubble.user .md-table tbody tr:hover,
.message-bubble.user .md-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
/* 数学公式 */
.md-math-block {
display: block;
overflow-x: auto;
padding: 10px 0;
text-align: center;
}
.md-math-inline {
display: inline;
padding: 0 2px;
}
/* 代码块包装器 + 复制按钮 */
.md-code-wrapper {
position: relative;
margin: 12px 0;
}
.md-code-wrapper .md-code-block {
margin: 0;
border-radius: var(--radius-md);
}
.md-code-copy {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 10px;
font-size: 12px;
color: #6B7280;
background: #F3F4F6;
border: 1px solid #E5E7EB;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
z-index: 2;
}
.md-code-wrapper:hover .md-code-copy {
opacity: 1;
}
/* 删除线 */
del {
text-decoration: line-through;
opacity: 0.6;
}
.md-hr {
border: none;
border-top: 1px solid var(--color-border);
@@ -1045,3 +1152,387 @@
border-color: transparent;
transform: none;
}
/* ===== AI 生成媒体:固定缩略图 + 下载 ===== */
.md-text-seg {
display: block;
width: 100%;
}
.chat-media-wrap {
position: relative;
display: inline-block;
max-width: 100%;
margin: 2px 0;
}
.chat-media-img {
display: block;
max-width: 320px;
max-height: 320px;
width: auto;
height: auto;
object-fit: contain;
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
cursor: zoom-in;
box-shadow: var(--shadow-xs);
}
.chat-media-video {
display: block;
max-width: 360px;
max-height: 320px;
width: auto;
height: auto;
border-radius: var(--radius-md);
background: #000;
cursor: zoom-in;
box-shadow: var(--shadow-xs);
}
.chat-media-bar {
margin-top: 6px;
}
.chat-media-link {
font-size: var(--text-xs);
color: var(--color-accent);
text-decoration: none;
}
.chat-media-link:hover {
text-decoration: underline;
}
/* 下载按钮 */
.chat-media-dl-btn {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast);
z-index: 2;
}
.chat-media-wrap:hover .chat-media-dl-btn {
opacity: 1;
}
.chat-media-dl-btn:hover {
background: rgba(0, 0, 0, 0.75);
}
/* 下载弹层 */
.chat-media-dl-pop {
position: absolute;
top: 44px;
right: 8px;
z-index: 20;
width: 220px;
padding: var(--space-md);
background: var(--color-card-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
animation: fadeIn var(--transition-fast);
}
.chat-media-dl-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.chat-media-dl-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.chat-media-dl-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.chat-media-dl-fmts {
display: flex;
gap: 4px;
}
.chat-media-dl-fmt {
flex: 1;
padding: 4px 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.chat-media-dl-fmt.active {
color: var(--color-text-inverse);
background: var(--color-accent);
border-color: var(--color-accent);
}
.chat-media-dl-sizes {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-media-dl-size {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.chat-media-dl-size:hover {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.chat-media-dl-size-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.chat-media-dl-size-px {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* 灯箱预览 */
.chat-lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-overlay);
animation: fadeIn 0.2s ease;
}
.chat-lightbox-close {
position: absolute;
top: 18px;
right: 22px;
width: 40px;
height: 40px;
border-radius: var(--radius-full);
border: none;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: background var(--transition-fast);
}
.chat-lightbox-close:hover {
background: rgba(255, 255, 255, 0.3);
}
.chat-lightbox-content {
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);
}
+692 -53
View File
@@ -4,40 +4,356 @@ import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
import type { Conversation, Message } from '@/types';
import './ChatPage.css';
// ===== Simple Markdown Renderer =====
// ===== 增强渲染依赖 =====
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import katex from 'katex';
import 'katex/dist/katex.min.css';
// ===== Enhanced Markdown Renderer =====
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
// 全屏灯箱预览
const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
return (
<div className="chat-lightbox" onClick={onClose}>
<button className="chat-lightbox-close" onClick={onClose} aria-label="关闭">×</button>
<div className="chat-lightbox-content" onClick={(e) => e.stopPropagation()}>
{type === 'video'
? <video src={src} controls autoPlay style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />
: <img src={src} alt="" style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />}
</div>
</div>
);
};
// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
function computeSizes(natural: { w: number; h: number }) {
if (natural.w && natural.h) {
const ratio = natural.w / natural.h;
const fit = (maxDim: number) => ratio >= 1
? { w: maxDim, h: Math.round(maxDim / ratio) }
: { w: Math.round(maxDim * ratio), h: maxDim };
return [
{ label: '小图', ...fit(512) },
{ label: '中图', ...fit(1024) },
{ label: '原始', w: natural.w, h: natural.h },
];
}
// 未知原始尺寸时按生成默认 4:3 给出三档
return [
{ label: '小图', w: 512, h: 384 },
{ label: '中图', w: 1024, h: 768 },
{ label: '大图', w: 2048, h: 1536 },
];
}
// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
const fallback = () => window.open(src, '_blank');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) { fallback(); return; }
if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
ctx.drawImage(img, 0, 0, w, h);
const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
canvas.toBlob((blob) => {
if (!blob) { fallback(); return; }
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, mime, 0.92);
} catch (e) {
fallback();
}
};
img.onerror = fallback;
img.src = src;
}
// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
const [open, setOpen] = useState(false);
const [dlOpen, setDlOpen] = useState(false);
const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
if (info.type === 'video') {
return (
<div className="chat-media-wrap">
<video className="chat-media-video" src={info.src} controls preload="metadata" onClick={() => setOpen(true)} />
<div className="chat-media-bar">
<a className="chat-media-link" href={info.src} target="_blank" rel="noopener" onClick={(e) => e.stopPropagation()}>打开 / 下载原视频</a>
</div>
{open && <ChatLightbox src={info.src} type="video" onClose={() => setOpen(false)} />}
</div>
);
}
const sizes = computeSizes(natural);
return (
<div className="chat-media-wrap">
<img
className="chat-media-img"
src={info.src}
alt={info.alt || '生成图片'}
loading="lazy"
onClick={() => setOpen(true)}
onLoad={(e) => {
const im = e.currentTarget;
if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
}}
/>
<button className="chat-media-dl-btn" title="下载图片" onClick={(e) => { e.stopPropagation(); setDlOpen((o) => !o); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
{dlOpen && (
<div className="chat-media-dl-pop" onClick={(e) => e.stopPropagation()}>
<div className="chat-media-dl-title">下载图片</div>
<div className="chat-media-dl-row">
<span className="chat-media-dl-label">格式</span>
<div className="chat-media-dl-fmts">
{(['png', 'jpg', 'webp'] as const).map((f) => (
<button key={f} className={`chat-media-dl-fmt ${fmt === f ? 'active' : ''}`} onClick={() => setFmt(f)}>{f.toUpperCase()}</button>
))}
</div>
</div>
<div className="chat-media-dl-label">尺寸</div>
<div className="chat-media-dl-sizes">
{sizes.map((s, i) => (
<button key={i} className="chat-media-dl-size" onClick={() => { downloadImage(info.src, fmt, s.w, s.h); setDlOpen(false); }}>
<span className="chat-media-dl-size-label">{s.label}</span>
<span className="chat-media-dl-size-px">{s.w}×{s.h}</span>
</button>
))}
</div>
</div>
)}
{open && <ChatLightbox src={info.src} type="img" onClose={() => setOpen(false)} />}
</div>
);
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
const html = useMemo(() => {
const nodes = useMemo(() => {
let text = content;
// Code blocks (```)
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => {
return `<pre class="md-code-block"><code class="md-code">${escapeHtml(code.trim())}</code></pre>`;
// 1. Extract code blocks first (protect from other replacements)
const codeBlocks: string[] = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
const idx = codeBlocks.length;
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
return '\x00CODEBLOCK' + idx + '\x00';
});
// Inline code (`)
text = text.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
// Bold (**text**)
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic (*text*)
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Headers (## heading)
// 2. Extract inline code
const inlineCodes: string[] = [];
text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
const idx = inlineCodes.length;
inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
return '\x00INLINE' + idx + '\x00';
});
// 2.5 Extract media blocks (<img>/<video>) → 结构化,后续渲染为 React 组件
const mediaBlocks: MediaInfo[] = [];
text = text.replace(/<(img|video)([^>]*?)\/?>/gi, (_m: string, tag: string, attrs: string) => {
const type = tag.toLowerCase() === 'img' ? 'img' : 'video';
const srcM = attrs.match(/src=["']([^"']+)["']/i);
const altM = attrs.match(/alt=["']([^"']*)["']/i);
mediaBlocks.push({ type, src: srcM ? srcM[1] : '', alt: altM ? altM[1] : '' });
return '\x00MEDIA' + (mediaBlocks.length - 1) + '\x00';
});
// 2.7 Extract Markdown tables: | h1 | h2 |\n|---|---| ... |
// 把整段表格替换为占位符,表格内容(已转义)放入 tableBlocks 数组
const tableBlocks: string[] = [];
text = text.replace(
/((?:^\|.+\|\r?\n)+^\|[\s\-:|]+\|\r?\n(?:^\|.+\|\r?\n?)+)/gm,
(_m: string, block: string) => {
const lines = block.trim().split(/\r?\n/).filter((l: string) => l.trim());
if (lines.length < 2) return block;
const headerCells = lines[0].split('|').slice(1, -1).map((c: string) => c.trim());
const alignCells = lines[1].split('|').slice(1, -1).map((c: string) => {
const t = c.trim();
const left = t.startsWith(':');
const right = t.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return 'left';
});
const bodyRows = lines.slice(2).map((row: string) =>
row.split('|').slice(1, -1).map((c: string) => c.trim())
);
const thead = headerCells.map((c: string, i: number) =>
`<th style="text-align:${alignCells[i] || 'left'}">${c}</th>`
).join('');
const tbody = bodyRows.map((r: string[]) =>
'<tr>' + r.map((c: string, i: number) =>
`<td style="text-align:${alignCells[i] || 'left'}">${c}</td>`
).join('') + '</tr>'
).join('');
const html = `<table class="md-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
const idx = tableBlocks.length;
tableBlocks.push(html);
return '\x00TABLE' + idx + '\x00';
}
);
// 2.8 LaTeX 数学公式:$$...$$(块级)和 $...$(行内)
// 提取为占位符,渲染后用 KaTeX 处理
const mathBlocks: string[] = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: true, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<div class="md-math-block">${rendered}</div>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
text = text.replace(/\$([^\s$][^$]*[^\s$])\$/g, (_m: string, formula: string) => {
try {
const rendered = katex.renderToString(formula.trim(), { displayMode: false, throwOnError: false });
const idx = mathBlocks.length;
mathBlocks.push(`<span class="md-math-inline">${rendered}</span>`);
return '\x00MATHBLOCK' + idx + '\x00';
} catch { return _m; }
});
// 2.9 Strikethrough: ~~text~~ → <del>text</del>
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 2.10 Auto-link bare URLs: http(s)://... → <a>
text = text.replace(/(?<![="'>])https?:\/\/[^\s<>[\]()]+/g, (url: string) =>
`<a class="md-link" href="${url}" target="_blank" rel="noopener">${url}</a>`
);
// 3. Headers
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// Unordered list items
text = text.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>');
// Ordered list items
text = text.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Horizontal rule
text = text.replace(/^---$/gm, '<hr class="md-hr">');
// Paragraph spacing: double newlines
text = text.replace(/\n\n/g, '<br/><br/>');
// Single newlines to <br>
// 4. Horizontal rule
text = text.replace(/^---+$/gm, '<hr class="md-hr">');
// 5. Blockquotes
text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
// 6. Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Bold and italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
// 8. Lists
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
// 9. Convert double newlines to paragraph breaks, single to <br/>
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, '<br/>');
return text;
// 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00TABLE(\d+)\x00/g, (_m: string, idx: string) => tableBlocks[parseInt(idx)] || '');
text = text.replace(/\x00MATHBLOCK(\d+)\x00/g, (_m: string, idx: string) => mathBlocks[parseInt(idx)] || '');
// 10.5 Apply syntax highlighting to code blocks + add copy button
text = text.replace(
/<pre class="md-code-block"><code class="md-code">([\s\S]*?)<\/code><\/pre>/g,
(_m: string, code: string) => {
const decoded = code.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
let highlighted: string;
try { highlighted = hljs.highlightAuto(decoded).value; } catch { highlighted = code; }
return `<div class="md-code-wrapper">
<button class="md-code-copy" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('code').textContent);this.textContent='已复制';setTimeout(()=>this.textContent='复制',2000)">复制</button>
<pre class="md-code-block"><code class="md-code hljs">${highlighted}</code></pre>
</div>`;
}
);
// 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>';
text = text.replace(/<p class="md-p"><\/p>/g, '');
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<table[\s\S]*?<\/table>)<\/p>/g, '$1');
// 12. 拆分为文本片段与媒体组件
const parts = text.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) {
const info = mediaBlocks[parseInt(m[1])];
if (info && info.src) return <ChatMedia key={'m' + i} info={info} />;
return null;
}
if (!part) return null;
// 仅清除被媒体切分产生的悬空 <p> 标签,保留段落内部的成对 <p>
let s = part.replace(/^<\/p>/, '').replace(/<p class="md-p">$/, '');
if (!s.trim()) return null;
return <div key={'t' + i} className="md-text-seg" dangerouslySetInnerHTML={{ __html: s }} />;
});
}, [content]);
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
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
.replace(/&/g, '&amp;')
@@ -159,6 +475,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"/>
@@ -261,7 +608,7 @@ const ChatPage: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedModelId, setSelectedModelId] = useState('');
const [selectedModelId, setSelectedModelId] = useState('auto');
const [chatModels, setChatModels] = useState<any[]>([]);
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
@@ -272,10 +619,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);
@@ -293,7 +647,7 @@ const ChatPage: React.FC = () => {
const models = Array.isArray(data) ? data : (data?.items || []);
setChatModels(models);
if (models.length > 0 && !selectedModelId) {
setSelectedModelId(models[0].id);
setSelectedModelId('auto');
}
}).catch(() => {
setChatModels([]);
@@ -358,6 +712,12 @@ const ChatPage: React.FC = () => {
createdAt: m.createdAt || m.created_at || new Date().toISOString(),
}));
setMessages(mapped);
// 页面刷新时若仍有「⏳」占位的媒体消息,继续轮询后端回写结果
mapped.forEach((m) => {
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.startsWith('⏳')) {
startMediaPolling(id, m.id);
}
});
} catch (err) {
console.error('加载消息失败', err);
setMessages([]);
@@ -368,6 +728,59 @@ const ChatPage: React.FC = () => {
loadMessages();
}, [id]);
// 媒体(图像/视频)异步生成轮询:后端先返回「⏳」占位,前端定时拉取该消息最新内容并回写
const pollMediaRef = useRef<Record<string, ReturnType<typeof setInterval> | null>>({});
const stopMediaPolling = (msgId: string) => {
const t = pollMediaRef.current[msgId];
if (t) {
clearInterval(t);
pollMediaRef.current[msgId] = null;
}
};
const startMediaPolling = (convId: string, msgId: string) => {
if (pollMediaRef.current[msgId]) return; // 已在轮询
let ticks = 0;
const timer = setInterval(async () => {
ticks += 1;
try {
const res = await chatAPI.getMessages(convId);
const msgs: any[] = unwrapList(res);
const updated = msgs.find((m: any) => m.id === msgId);
if (!updated) {
stopMediaPolling(msgId);
return;
}
const content: string = updated.content || '';
if (content.startsWith('⏳')) {
if (ticks > 120) stopMediaPolling(msgId); // ~6 分钟兜底
return;
}
// 生成完成:回写消息内容
setMessages(prev => prev.map(m => m.id === msgId ? {
...m,
content,
tokenUsed: updated.tokenUsed ?? updated.token_used ?? m.tokenUsed,
createdAt: updated.createdAt || updated.created_at || m.createdAt,
used_skills: updated.usedSkills ?? updated.used_skills ?? m.used_skills,
used_mcp: updated.usedMcp ?? updated.used_mcp ?? m.used_mcp,
} : m));
stopMediaPolling(msgId);
} catch (e) {
if (ticks > 120) stopMediaPolling(msgId); // 网络异常兜底
}
}, 3000);
pollMediaRef.current[msgId] = timer;
};
// 卸载时清理所有轮询定时器
useEffect(() => {
return () => {
Object.values(pollMediaRef.current).forEach(t => t && clearInterval(t));
};
}, []);
// Load knowledge bases, agents, skills for attachment dropdowns
useEffect(() => {
knowledgeAPI.getBases().then((res: any) => {
@@ -387,16 +800,23 @@ const ChatPage: React.FC = () => {
let apps: any[] = [];
if (Array.isArray(data)) apps = data;
else if (data?.items) apps = data.items;
// 过滤已卸载和未安装的agent
setAgents(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
// 显示所有已发布的Agent(不需要安装即可在聊天中使用)
setAgents(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
}).catch(() => {});
marketAPI.getApps({ type: 'skill' }).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;
// 过滤已卸载和未安装的skill
setSkills(apps.filter((a: any) => a.installed !== false && a.status !== 'removed'));
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
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(() => {});
}, []);
@@ -417,8 +837,12 @@ const ChatPage: React.FC = () => {
return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
}, [conversations, searchQuery]);
// Selected model info
const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' };
// Selected model info(Auto 自动:按对话意图自动匹配后台各能力默认模型)
const isAutoModel = selectedModelId === 'auto';
const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', status: 'free', provider_name: '按能力智能匹配', capabilities: [], price_per_million_tokens: 0 };
const selectedModel = isAutoModel
? AUTO_MODEL
: (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL);
// Scroll to bottom when messages change
useEffect(() => {
@@ -435,7 +859,7 @@ const ChatPage: React.FC = () => {
// Handle new conversation
const handleNewConversation = async () => {
try {
const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId });
const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId === 'auto' ? (chatModels[0]?.id || '') : selectedModelId });
const newConv = unwrapData(res);
const conv: Conversation = {
id: newConv.id,
@@ -547,8 +971,10 @@ const ChatPage: React.FC = () => {
kbName: res.data.knowledge_base_name,
kbId: res.data.knowledge_base_id,
});
// 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
const newCount = res.data.knowledge_graph_count ?? 1;
setConversations(prev =>
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: (c.knowledgeGraphCount || 0) + 1 } : c)
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
);
} else {
setKgError(res.message || '转换失败');
@@ -559,10 +985,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
@@ -582,8 +1007,12 @@ const ChatPage: React.FC = () => {
model_id: selectedModelId || undefined,
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
knowledge_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
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);
@@ -611,13 +1040,21 @@ const ChatPage: React.FC = () => {
tokenUsed: data.assistant_message.token_used || 0,
createdAt: data.assistant_message.created_at || new Date().toISOString(),
used_skills: data.assistant_message.used_skills || [],
used_mcp: data.assistant_message.used_mcp || [],
});
}
return [...filtered, ...newMsgs];
});
// 媒体异步生成:启动轮询,待后端回写后自动刷新
if (data.assistant_message?.generating) {
startMediaPolling(selectedConversation!.id, data.assistant_message.id);
}
// Refresh conversations to update lastMessage
loadConversations();
// 发送后清空本次附件(选择状态仍保留)
setAttachments([]);
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
if (selectedConversation?.id && data.assistant_message) {
@@ -640,6 +1077,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) {
@@ -744,6 +1258,17 @@ const ChatPage: React.FC = () => {
</div>
}
>
<button
key="auto"
className={`dropdown-item ${selectedModelId === 'auto' ? 'selected' : ''}`}
onClick={() => setSelectedModelId('auto')}
>
<div className="model-dropdown-item">
<span>🤖 Auto 自动</span>
<span className="model-provider-tag">按能力智能匹配</span>
<span className="model-cap-badge">智能</span>
</div>
</button>
{chatModels.filter(m => m.is_enabled !== false).map(model => (
<button
key={model.id}
@@ -753,6 +1278,13 @@ const ChatPage: React.FC = () => {
<div className="model-dropdown-item">
<span>{model.name}</span>
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span>
{(() => {
const caps = model.capabilities || [];
const label = caps.includes('image') && caps.includes('video') ? '图像/视频'
: caps.includes('image') ? '图像'
: caps.includes('video') ? '视频' : '';
return label ? <span className="model-cap-badge">{label}</span> : null;
})()}
</div>
</button>
))}
@@ -778,18 +1310,35 @@ 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 usedMcp: string[] = (msg.role === 'assistant' && msg.used_mcp) ? msg.used_mcp : [];
// 模糊创作意图确认卡片
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">
@@ -797,7 +1346,7 @@ const ChatPage: React.FC = () => {
<span
key={skillName}
className="evo-skill-tag"
onClick={() => window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`}
onClick={() => { window.location.href = '/evolution?skill=' + encodeURIComponent(skillName); }}
title={`使用了专属技能:${skillName}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -811,6 +1360,27 @@ const ChatPage: React.FC = () => {
)}
</div>
)}
{msg.role === 'assistant' && usedMcp.length > 0 && (
<div className="evo-skill-tags-row">
{usedMcp.slice(0, 3).map(mcpName => (
<span
key={mcpName}
className="evo-skill-tag evo-mcp-tag"
title={`使用了 MCP 应用:${mcpName}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="9" x2="15" y2="15"/>
<line x1="15" y1="9" x2="9" y2="15"/>
</svg>
{mcpName}
</span>
))}
{usedMcp.length > 3 && (
<span className="evo-skill-tag evo-skill-tag-more">+{usedMcp.length - 3}</span>
)}
</div>
)}
<div className={`message-meta ${msg.role}`}>
<span className="message-time">{formatTime(msg.createdAt)}</span>
<span className="message-tokens">{msg.tokenUsed} tokens</span>
@@ -930,19 +1500,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}
+12 -12
View File
@@ -14,7 +14,7 @@
align-items: center;
justify-content: space-between;
padding: var(--space-xl) var(--space-2xl);
background: linear-gradient(135deg, #1A1A2E 0%, #252547 100%);
background: linear-gradient(135deg, #5B5FED 0%, #4A4ECC 100%);
color: #FFFFFF;
flex-shrink: 0;
gap: var(--space-2xl);
@@ -30,12 +30,12 @@
width: 44px;
height: 44px;
border-radius: var(--radius-lg);
background: rgba(91, 95, 237, 0.3);
border: 1px solid rgba(91, 95, 237, 0.5);
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.28);
display: flex;
align-items: center;
justify-content: center;
color: #A78BFA;
color: #FFFFFF;
}
.evo-header-title {
@@ -569,7 +569,7 @@
}
.evo-rank-num.top {
background: linear-gradient(135deg, #5B5FED, #A78BFA);
background: linear-gradient(135deg, #5B5FED, #8185F5);
color: #FFFFFF;
}
@@ -901,8 +901,8 @@
.evo-suggestion-item svg {
flex-shrink: 0;
margin-top: 1px;
color: #F59E0B;
fill: #F59E0B;
color: #E8922C;
fill: #E8922C;
}
/* ===== Shared ===== */
@@ -1187,8 +1187,8 @@
width: 56px;
height: 56px;
border-radius: 50%;
background: #FEF2F2;
color: #EF4444;
background: #FDEBEB;
color: #D9383A;
display: flex;
align-items: center;
justify-content: center;
@@ -1592,9 +1592,9 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.evo-toast-success { background: #10B981; }
.evo-toast-success { background: #219B5E; }
.evo-toast-info { background: #5B5FED; }
.evo-toast-error { background: #EF4444; }
.evo-toast-error { background: #D9383A; }
@keyframes toast-in {
from { opacity: 0; transform: translateX(100px); }
@@ -1669,7 +1669,7 @@
padding: 1px 6px;
border-radius: 10px;
background: #E6F7EE;
color: #10B981;
color: #219B5E;
font-weight: 600;
}
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more