Compare commits

...

34 Commits
main ... v3.2.3

Author SHA1 Message Date
肖应宇 557960a10b test: 截图测试office套件文件的展示 2026-04-11 14:24:13 +08:00
肖应宇 fe40a1b479 chore(backend): 强化输出文件的 present_files 交付约束 2026-04-11 11:35:28 +08:00
肖应宇 2deeb9f967 feat(frontend): 支持 DOCX/PDF 下载时包含图片资源 2026-04-11 11:35:10 +08:00
肖应宇 e5c0e9d584 fix: 修复剪贴板没有统一使用copyToClipboard的问题 2026-04-11 10:21:29 +08:00
肖应宇 5a7de8b148 feat(artifact): 禁用自动打开artifact面板的功能 2026-04-11 10:08:04 +08:00
肖应宇 f4af52a24a fix(frontend): 同意对话错误提示和增加两条e2e测试 2026-04-11 09:39:33 +08:00
肖应宇 72a92707ca test: 测试用例测试html文件有没有向用户展示 2026-04-11 09:19:39 +08:00
肖应宇 a9f98aaf71 feat(backend): 提示词把present_files,写成了present_file,可能是不展示html文件的原因 2026-04-11 09:17:55 +08:00
肖应宇 0e7412bab4 feat: 完成显示docx, pptx, xlsx文件 2026-04-10 17:56:18 +08:00
肖应宇 51e795a289 feat(frontend): 优化工作区输入框与 artifacts 展示体验
改进工作区核心交互,提升输入与结果查看的一致性和可用性。

调整 prompt 输入相关组件逻辑,优化输入行为与状态反馈
更新 workspace input-box 交互细节,改善可用性与稳定性
优化 message-group 展示逻辑,增强消息区域可读性
调整 artifact-file-detail 预览相关实现,为后续 Office 文件展示做准备
补充并更新 thread-routing e2e 用例,覆盖关键路由与交互回归场景
2026-04-10 17:19:41 +08:00
肖应宇 c2ddb1cee5 test: 新增新用户的创建逻辑用例 2026-04-09 17:08:59 +08:00
肖应宇 39d2807bcf dev: 从侧边栏点击直接进入对话页 2026-04-09 14:23:28 +08:00
肖应宇 20b48f6185 fix(frontend): 进入/new预创建会话并强制跳转聊天态 2026-04-09 13:31:03 +08:00
肖应宇 19b888f69a feat:重启tag的删除功能 2026-04-09 13:14:14 +08:00
肖应宇 0a619f6603 fix(frontend): hide history reliably in welcome mode 2026-04-09 13:12:08 +08:00
肖应宇 883dd58275 fix(frontend): stabilize thread id when sending messages 2026-04-09 13:11:50 +08:00
肖应宇 df95190d70 Merge remote-tracking branch 'origin/git-main' into feat/git-main-frondend-intergretion-oldhash-20260408-165134 2026-04-09 12:06:07 +08:00
肖应宇 9d82ed43d9 feat: 如果请求失败不要写入localstorage,且不要展示失败的skill 2026-04-09 11:52:38 +08:00
肖应宇 576a3c37b6 feat: 全局字体和代码块字体大小 2026-04-09 11:52:01 +08:00
肖应宇 ec2475a323 feat: skill tag的复数处理。测试复skill的数量 2026-04-09 11:37:51 +08:00
肖应宇 9417348b94 feat: enter换行,取消enter发送 2026-04-09 11:30:51 +08:00
肖应宇 0a98af35e9 dev: 给通信面板加收起按钮 2026-04-09 11:09:02 +08:00
肖应宇 ab49f840d5 feat: 清空旧localstorage的内容 2026-04-09 11:07:34 +08:00
肖应宇 c266736ede feat: 修改测试标识的位置,并写死会话标题为“来,一起学习工作吧” 2026-04-09 11:04:01 +08:00
肖应宇 905836bdac feat: 生成中禁用返回按钮 2026-04-09 10:41:52 +08:00
肖应宇 38fba7c241 dev: 测试版本标识 2026-04-09 10:29:17 +08:00
肖应宇 70ab78cbb0 Merge branch 'feat/git-main-frondend-intergretion-oldhash-20260408-165134' of https://git.xueai.art/skills/deerflow2 into feat/git-main-frondend-intergretion-oldhash-20260408-165134 2026-04-09 10:27:18 +08:00
肖应宇 cea4232813 feat: 宿主页复制 2026-04-09 10:27:14 +08:00
肖应宇 25c444e83d feat: skill清空逻辑。因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 2026-04-09 10:23:27 +08:00
肖应宇 f68c09f90a feat: 支持多技能标签展示并持久化已选技能 2026-04-09 09:58:01 +08:00
肖应宇 5efe4191f8 feat: 弃用localstorage的设置 2026-04-08 20:12:38 +08:00
肖应宇 739d10a6ec dev: 测试版本标识 2026-04-08 17:23:16 +08:00
肖应宇 e48a6ed064 fix:修复错误跳转无query的场景 2026-04-08 17:21:41 +08:00
肖应宇 0b0cbf9c5b feat(frontend): 支持宿主selectedSkills和skill bootstarp流程, 和加载skill中的加载提示与禁止发送消息 2026-04-08 17:15:35 +08:00
32 changed files with 2263 additions and 275 deletions

View File

@ -269,7 +269,8 @@ You: "Deploying to staging..." [proceed]
- Use `read_file` tool to read uploaded files using their paths from the list - Use `read_file` tool to read uploaded files using their paths from the list
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals - For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
- All temporary work happens in `/mnt/user-data/workspace` - All temporary work happens in `/mnt/user-data/workspace`
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` tool - Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
- MANDATORY delivery sequence for Markdown/HTML outputs: after `write_file` (or `str_replace`) creates/updates a deliverable `.md` or `.html` in `/mnt/user-data/outputs`, you MUST call `present_files` for that file before finishing your response
{acp_section} {acp_section}
</working_directory> </working_directory>
@ -347,8 +348,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks. {subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
- Progressive Loading: Load resources incrementally as referenced in skills - Progressive Loading: Load resources incrementally as referenced in skills
- Output Files: Final deliverables must be in `/mnt/user-data/outputs` - Output Files: Final deliverables must be in `/mnt/user-data/outputs`
- Delivery Completeness: If you created/updated a deliverable `.md` or `.html` file in `/mnt/user-data/outputs`, do NOT end the task until you have called `present_files` for it
- Clarity: Be direct and helpful, avoid unnecessary meta-commentary - Clarity: Be direct and helpful, avoid unnecessary meta-commentary
- Including Images and Mermaid: Images and Mermaid diagrams are always welcomed in the Markdown format, and you're encouraged to use `![Image Description](image_path)\n\n` or "```mermaid" to display images in response or Markdown files
- Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance - Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance
- Language Consistency: Keep using the same language as user's - Language Consistency: Keep using the same language as user's
- Always Respond: Your thinking is internal. You MUST always provide a visible response to the user after thinking. - Always Respond: Your thinking is internal. You MUST always provide a visible response to the user after thinking.
@ -495,7 +496,7 @@ def _build_acp_section() -> str:
"- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n" "- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n"
"- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n" "- When writing prompts for ACP agents, describe the task only — do NOT reference `/mnt/user-data` paths\n"
"- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n" "- ACP agent results are accessible at `/mnt/acp-workspace/` (read-only) — use `ls`, `read_file`, or `bash cp` to retrieve output files\n"
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`" "- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_files`"
) )

View File

@ -63,11 +63,13 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.6.1", "docx": "^9.6.1",
"docx-preview": "^0.3.7",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"hast": "^1.0.0", "hast": "^1.0.0",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"jszip": "^3.10.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"marked": "^17.0.5", "marked": "^17.0.5",
@ -79,6 +81,7 @@
"nextra-theme-docs": "^4.6.1", "nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13", "nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11", "ogl": "^1.0.11",
"pptx-preview": "^1.0.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-resizable-panels": "^4.4.1", "react-resizable-panels": "^4.4.1",
@ -94,6 +97,7 @@
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -137,6 +137,9 @@ importers:
docx: docx:
specifier: ^9.6.1 specifier: ^9.6.1
version: 9.6.1 version: 9.6.1
docx-preview:
specifier: ^0.3.7
version: 0.3.7
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.4 version: 17.2.4
@ -152,6 +155,9 @@ importers:
html2pdf.js: html2pdf.js:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.0 version: 0.14.0
jszip:
specifier: ^3.10.1
version: 3.10.1
katex: katex:
specifier: ^0.16.28 specifier: ^0.16.28
version: 0.16.28 version: 0.16.28
@ -185,6 +191,9 @@ importers:
ogl: ogl:
specifier: ^1.0.11 specifier: ^1.0.11
version: 1.0.11 version: 1.0.11
pptx-preview:
specifier: ^1.0.7
version: 1.0.7
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4 version: 19.2.4
@ -230,6 +239,9 @@ importers:
uuid: uuid:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.0.0 version: 13.0.0
xlsx:
specifier: ^0.18.5
version: 0.18.5
zod: zod:
specifier: ^3.24.2 specifier: ^3.24.2
version: 3.25.76 version: 3.25.76
@ -2548,6 +2560,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ai@6.0.78: ai@6.0.78:
resolution: {integrity: sha512-eriIX/NLWfWNDeE/OJy8wmIp9fyaH7gnxTOCPT5bp0MNkvORstp1TwRUql9au8XjXzH7o2WApqbwgxJDDV0Rbw==} resolution: {integrity: sha512-eriIX/NLWfWNDeE/OJy8wmIp9fyaH7gnxTOCPT5bp0MNkvORstp1TwRUql9au8XjXzH7o2WApqbwgxJDDV0Rbw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2803,6 +2819,10 @@ packages:
ccount@2.0.1: ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2875,6 +2895,10 @@ packages:
codemirror@6.0.2: codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
collapse-white-space@2.1.0: collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@ -2934,6 +2958,11 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crelt@1.0.6: crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@ -3206,6 +3235,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
docx@9.6.1: docx@9.6.1:
resolution: {integrity: sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==} resolution: {integrity: sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3221,6 +3253,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
embla-carousel-react@8.6.0: embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies: peerDependencies:
@ -3553,6 +3588,10 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'} engines: {node: '>=0.4.x'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
framer-motion@12.34.0: framer-motion@12.34.0:
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
peerDependencies: peerDependencies:
@ -4190,6 +4229,9 @@ packages:
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -4818,6 +4860,9 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
pptx-preview@1.0.7:
resolution: {integrity: sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -5274,6 +5319,10 @@ packages:
resolution: {integrity: sha512-SBMgkuJYvP4F62daRfBNwYC2nXTEhNXAfsBZ/BB7Ly85/KnbnjmKM7/45ZrFbH6jIMiAliDUDPSZFUuXDvcg6A==} resolution: {integrity: sha512-SBMgkuJYvP4F62daRfBNwYC2nXTEhNXAfsBZ/BB7Ly85/KnbnjmKM7/45ZrFbH6jIMiAliDUDPSZFUuXDvcg6A==}
hasBin: true hasBin: true
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@ -5449,6 +5498,9 @@ packages:
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -5800,10 +5852,23 @@ packages:
wicked-good-xpath@1.3.0: wicked-good-xpath@1.3.0:
resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-js@1.6.11: xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true hasBin: true
@ -5836,6 +5901,9 @@ packages:
zod@4.3.6: zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
zustand@4.5.7: zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'} engines: {node: '>=12.7.0'}
@ -8234,6 +8302,8 @@ snapshots:
acorn@8.15.0: {} acorn@8.15.0: {}
adler-32@1.3.1: {}
ai@6.0.78(zod@3.25.76): ai@6.0.78(zod@3.25.76):
dependencies: dependencies:
'@ai-sdk/gateway': 3.0.39(zod@3.25.76) '@ai-sdk/gateway': 3.0.39(zod@3.25.76)
@ -8476,6 +8546,11 @@ snapshots:
ccount@2.0.1: {} ccount@2.0.1: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -8564,6 +8639,8 @@ snapshots:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.13 '@codemirror/view': 6.39.13
codepage@1.15.0: {}
collapse-white-space@2.1.0: {} collapse-white-space@2.1.0: {}
color-convert@2.0.1: color-convert@2.0.1:
@ -8609,6 +8686,8 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 layout-base: 2.0.1
crc-32@1.2.2: {}
crelt@1.0.6: {} crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@ -8899,6 +8978,10 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
docx@9.6.1: docx@9.6.1:
dependencies: dependencies:
'@types/node': 25.5.0 '@types/node': 25.5.0
@ -8920,6 +9003,11 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
embla-carousel-react@8.6.0(react@19.2.4): embla-carousel-react@8.6.0(react@19.2.4):
dependencies: dependencies:
embla-carousel: 8.6.0 embla-carousel: 8.6.0
@ -9449,6 +9537,8 @@ snapshots:
format@0.2.2: {} format@0.2.2: {}
frac@1.1.2: {}
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies: dependencies:
motion-dom: 12.34.0 motion-dom: 12.34.0
@ -10154,6 +10244,8 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.18.1: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@ -11179,6 +11271,14 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
pptx-preview@1.0.7:
dependencies:
echarts: 5.6.0
jszip: 3.10.1
lodash: 4.18.1
tslib: 2.8.1
uuid: 10.0.0
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1): prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1):
@ -11771,6 +11871,10 @@ snapshots:
commander: 13.1.0 commander: 13.1.0
wicked-good-xpath: 1.3.0 wicked-good-xpath: 1.3.0
ssf@0.11.2:
dependencies:
frac: 1.1.2
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stackblur-canvas@2.7.0: stackblur-canvas@2.7.0:
@ -11966,6 +12070,8 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-bom: 3.0.0 strip-bom: 3.0.0
tslib@2.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
@ -12335,8 +12441,22 @@ snapshots:
wicked-good-xpath@1.3.0: {} wicked-good-xpath@1.3.0: {}
wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
word@0.3.0: {}
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-js@1.6.11: xml-js@1.6.11:
dependencies: dependencies:
sax: 1.6.0 sax: 1.6.0
@ -12357,6 +12477,10 @@ snapshots:
zod@4.3.6: {} zod@4.3.6: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
zustand@4.5.7(@types/react@19.2.13)(react@19.2.4): zustand@4.5.7(@types/react@19.2.13)(react@19.2.4):
dependencies: dependencies:
use-sync-external-store: 1.6.0(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4)

View File

@ -2,7 +2,8 @@
import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react"; import { FilesIcon, ListTodoIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ConversationEmptyState } from "@/components/ai-elements/conversation"; import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -28,6 +29,7 @@ import { ThreadTitle } from "@/components/workspace/thread-title";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@ -66,13 +68,18 @@ export default function ChatPage() {
// 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。 // 新逻辑:历史渲染和新会话仅由路由 /chats/new 控制,不再读取 isnew/is_chatting 参数。
const shouldRenderHistory = !showWelcomeStyle; const shouldRenderHistory = !showWelcomeStyle;
const createNewSession = useMemo(() => isNewThread, [isNewThread]);
const safeThreadId = useMemo(() => { const safeThreadId = useMemo(() => {
if (!threadId || threadId === "new") { if (!threadId || threadId === "new") {
return undefined; return undefined;
} }
return threadId; return threadId;
}, [threadId]); }, [threadId]);
// `/new` + `thread_id` now reuses the pre-created thread, instead of creating
// a new session on first submit.
const createNewSession = useMemo(
() => isNewThread && !safeThreadId,
[isNewThread, safeThreadId],
);
const streamThreadId = useMemo(() => { const streamThreadId = useMemo(() => {
if (isNewThread && createNewSession) { if (isNewThread && createNewSession) {
@ -80,9 +87,38 @@ export default function ChatPage() {
} }
return safeThreadId; return safeThreadId;
}, [createNewSession, isNewThread, safeThreadId]); }, [createNewSession, isNewThread, safeThreadId]);
const apiClient = useMemo(() => getAPIClient(isMock), [isMock]);
const warnedMissingThreadIdRef = useRef(false);
const initializedThreadRef = useRef<string | null>(null);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
useEffect(() => {
if (!isNewThread) {
warnedMissingThreadIdRef.current = false;
return;
}
if (!safeThreadId) {
if (!warnedMissingThreadIdRef.current) {
warnedMissingThreadIdRef.current = true;
toast.error("缺少 thread_id无法创建会话");
}
return;
}
warnedMissingThreadIdRef.current = false;
if (initializedThreadRef.current === safeThreadId) return;
initializedThreadRef.current = safeThreadId;
void apiClient.threads
.create({
threadId: safeThreadId,
ifExists: "do_nothing",
})
.catch(() => {
initializedThreadRef.current = null;
toast.error("会话创建失败,请稍后重试");
});
}, [apiClient, isNewThread, safeThreadId]);
// 监听宿主页 selectedSkill 消息 // 监听宿主页 selectedSkill 消息
const { const {
skillError: selectedSkillError, skillError: selectedSkillError,
@ -135,10 +171,16 @@ export default function ChatPage() {
setHistoryCutoff(null); setHistoryCutoff(null);
return; return;
} }
if (historyCutoff === null && !thread.isThreadLoading) { if (hasSubmitted) return;
setHistoryCutoff(thread.messages.length); // Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
} // 这样即使历史消息是后续异步补齐,也不会重新露出。
setHistoryCutoff((prev) => {
const next = thread.messages.length;
if (prev === null) return next;
return next > prev ? next : prev;
});
}, [ }, [
hasSubmitted,
historyCutoff, historyCutoff,
shouldRenderHistory, shouldRenderHistory,
thread.isThreadLoading, thread.isThreadLoading,
@ -193,15 +235,30 @@ export default function ChatPage() {
const todoListCollapsed = true; const todoListCollapsed = true;
const [showExitDialog, setShowExitDialog] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false);
const isStreaming = isUploading || thread.isLoading;
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: Parameters<typeof sendMessage>[1]) => { (message: Parameters<typeof sendMessage>[1]) => {
if (isSelectedSkillBootstrapping) { if (isSelectedSkillBootstrapping) {
return; return;
} }
if (isNewThread && !safeThreadId) {
toast.error("缺少 thread_id无法发送消息");
return;
}
setHasSubmitted(true); setHasSubmitted(true);
void sendMessage(threadId, message); if (safeThreadId && (isNewThread || showWelcomeStyle)) {
router.replace(`/workspace/chats/${safeThreadId}?is_chatting=true`);
}
void sendMessage(safeThreadId, message);
}, },
[isSelectedSkillBootstrapping, sendMessage, threadId], [
isNewThread,
isSelectedSkillBootstrapping,
router,
safeThreadId,
sendMessage,
showWelcomeStyle,
],
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
@ -225,7 +282,7 @@ export default function ChatPage() {
return ( return (
<ThreadContext.Provider value={{ threadId,thread }}> <ThreadContext.Provider value={{ threadId, thread }}>
<div <div
className={cn( className={cn(
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out", "m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
@ -252,6 +309,7 @@ export default function ChatPage() {
size="sm" size="sm"
variant="ghost" variant="ghost"
className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80" className="px-[10px] py-[5px] text-sm font-medium text-[#150033] hover:text-[#150033]/80"
disabled={isStreaming}
onClick={() => setShowExitDialog(true)} onClick={() => setShowExitDialog(true)}
> >
<svg <svg
@ -272,8 +330,9 @@ export default function ChatPage() {
</Button> </Button>
</div> </div>
<div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]"> <div className="flex items-center justify-center overflow-hidden text-sm font-bold font-medium whitespace-nowrap text-[#333333]">
{/* threadTitle={title} */}
{title !== "Untitled" && ( {title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} /> <ThreadTitle threadId={threadId} threadTitle={'来,一起学习工作吧'} />
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2 overflow-hidden"> <div className="flex items-center justify-end gap-2 overflow-hidden">
@ -328,8 +387,10 @@ export default function ChatPage() {
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
messagesOverride={ messagesOverride={
shouldRenderHistory || historyCutoff === null shouldRenderHistory
? undefined ? undefined
: historyCutoff === null
? []
: thread.messages.slice(historyCutoff) : thread.messages.slice(historyCutoff)
} }
paddingBottom={todoListCollapsed ? 160 : 280} paddingBottom={todoListCollapsed ? 160 : 280}
@ -354,6 +415,7 @@ export default function ChatPage() {
<div <div
className={cn( className={cn(
"h-full w-full transition-transform duration-300 ease-in-out", "h-full w-full transition-transform duration-300 ease-in-out",
showWelcomeStyle && !hasSubmitted ? "translate-x-0" : "",
artifactPanelOpen ? "translate-x-0" : "translate-x-full", artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)} )}
> >
@ -390,9 +452,9 @@ export default function ChatPage() {
{t.common.artifacts} {t.common.artifacts}
</h2> </h2>
</header> </header>
<main className="min-h-0 grow"> <main className="min-h-0 grow overflow-auto">
<ArtifactFileList <ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12" className="max-w-(--container-width-sm) p-4 pt-12 mb-[207px]"
files={thread.values.artifacts ?? []} files={thread.values.artifacts ?? []}
threadId={threadId} threadId={threadId}
/> />
@ -444,7 +506,8 @@ export default function ChatPage() {
disabled={ disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isSelectedSkillBootstrapping || isSelectedSkillBootstrapping ||
isUploading isUploading ||
(isNewThread && !safeThreadId)
} }
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit} onSubmit={handleSubmit}

View File

@ -116,6 +116,7 @@ export const ChainOfThoughtHeader = memo(
export type ChainOfThoughtStepProps = ComponentProps<"div"> & { export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon | React.ReactElement; icon?: LucideIcon | React.ReactElement;
label: ReactNode; label: ReactNode;
action?: ReactNode;
description?: ReactNode; description?: ReactNode;
status?: "complete" | "active" | "pending"; status?: "complete" | "active" | "pending";
}; };
@ -125,6 +126,7 @@ export const ChainOfThoughtStep = memo(
className, className,
icon: Icon = DotIcon, icon: Icon = DotIcon,
label, label,
action,
description, description,
status = "complete", status = "complete",
children, children,
@ -151,7 +153,10 @@ export const ChainOfThoughtStep = memo(
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" /> <div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
</div> </div>
<div className="flex-1 space-y-2 overflow-hidden"> <div className="flex-1 space-y-2 overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div>{label}</div> <div>{label}</div>
{action && <div className="shrink-0">{action}</div>}
</div>
{description && ( {description && (
<div className="text-muted-foreground text-xs">{description}</div> <div className="text-muted-foreground text-xs">{description}</div>
)} )}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn, copyToClipboard } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { import {
type ComponentProps, type ComponentProps,
@ -146,14 +146,9 @@ export const CodeBlockCopyButton = ({
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext); const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => { const handleCopyClick = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try { try {
await navigator.clipboard.writeText(code); await copyToClipboard(code);
setIsCopied(true); setIsCopied(true);
onCopy?.(); onCopy?.();
setTimeout(() => setIsCopied(false), timeout); setTimeout(() => setIsCopied(false), timeout);
@ -167,7 +162,7 @@ export const CodeBlockCopyButton = ({
return ( return (
<Button <Button
className={cn("shrink-0", className)} className={cn("shrink-0", className)}
onClick={copyToClipboard} onClick={handleCopyClick}
size="icon" size="icon"
variant="ghost" variant="ghost"
{...props} {...props}

View File

@ -860,12 +860,15 @@ export const PromptInputBody = ({
export type PromptInputTextareaProps = ComponentProps< export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea typeof InputGroupTextarea
>; > & {
submitOnEnter?: boolean;
};
export const PromptInputTextarea = ({ export const PromptInputTextarea = ({
onChange, onChange,
className, className,
placeholder = "What would you like to know?", placeholder = "What would you like to know?",
submitOnEnter = true,
...props ...props
}: PromptInputTextareaProps) => { }: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
@ -877,7 +880,10 @@ export const PromptInputTextarea = ({
if (isComposing || e.nativeEvent.isComposing) { if (isComposing || e.nativeEvent.isComposing) {
return; return;
} }
if (e.shiftKey) { if (!submitOnEnter) {
return;
}
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return; return;
} }
e.preventDefault(); e.preventDefault();
@ -1083,10 +1089,12 @@ export const PromptInputSubmit = ({
controller.attachments.files.length > 0 controller.attachments.files.length > 0
: false; : false;
// 正在 streaming 时不允许发送 const isStreaming = status === "streaming";
const isStreaming = status === "streaming" || status === "submitted"; const isSubmitted = status === "submitted";
// Streaming 时按钮用于停止,不受输入内容是否为空限制
const isDisabled = disabled || !hasContent || isStreaming; const isDisabled = isStreaming
? !!disabled
: disabled || !hasContent || isSubmitted;
let Icon = <ArrowUpIcon className="size-4" />; let Icon = <ArrowUpIcon className="size-4" />;

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Tag({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="tag"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-transparent bg-[#EAE2F5] px-[15px] py-[4px] text-xs font-medium text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
export { Tag };

View File

@ -1,3 +1,4 @@
import JSZip from "jszip";
import { import {
DownloadIcon, DownloadIcon,
FileTextIcon, FileTextIcon,
@ -8,12 +9,15 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type CSSProperties,
type ComponentProps, type ComponentProps,
type HTMLAttributes, type HTMLAttributes,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
import * as XLSX from "xlsx";
import { import {
Artifact, Artifact,
@ -95,8 +99,8 @@ export function ArtifactFileDetail({
return checkCodeFile(filepath); return checkCodeFile(filepath);
}, [filepath, isWriteFile, isSkillFile]); }, [filepath, isWriteFile, isSkillFile]);
const previewable = useMemo(() => { const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown"; return language === "html" || language === "markdown";
}, [isWriteFile, language]); }, [language]);
const artifactUrl = useMemo(() => { const artifactUrl = useMemo(() => {
if (!threadId) { if (!threadId) {
return ""; return "";
@ -123,6 +127,8 @@ export function ArtifactFileDetail({
}); });
const displayContent = content ?? ""; const displayContent = content ?? "";
const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] =
useState(false);
const artifactOptions = useMemo(() => { const artifactOptions = useMemo(() => {
return (artifacts ?? []).map((artifactPath) => ({ return (artifacts ?? []).map((artifactPath) => ({
@ -145,19 +151,114 @@ export function ArtifactFileDetail({
}, },
}); });
const resolveMarkdownAssetUrlForDownload = useCallback(
(rawPath: string): string | null => {
const normalizedRef = normalizeReference(rawPath);
if (!normalizedRef) return null;
if (isExternalReference(normalizedRef)) return normalizedRef;
if (normalizedRef.startsWith("/mnt/user-data/")) {
return urlOfArtifact({ filepath: normalizedRef, threadId });
}
if (normalizedRef.startsWith("mnt/user-data/")) {
return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId });
}
const resolvedVirtualPath = resolveReferencedVirtualPath(
normalizedRef,
filepath,
);
if (!resolvedVirtualPath) return null;
return urlOfArtifact({ filepath: resolvedVirtualPath, threadId });
},
[filepath, threadId],
);
// 下载为 DOCX // 下载为 DOCX
const handleDownloadDocx = useCallback(() => { const handleDownloadDocx = useCallback(() => {
if (content) { if (content) {
void downloadAsDocx(content, fileName); void downloadAsDocx(content, fileName, {
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
});
} }
}, [content, fileName, downloadAsDocx]); }, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]);
// 下载为 PDF // 下载为 PDF
const handleDownloadPdf = useCallback(() => { const handleDownloadPdf = useCallback(() => {
if (content) { if (content) {
void downloadAsPdf(content, fileName); void downloadAsPdf(content, fileName, {
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
});
} }
}, [content, fileName, downloadAsPdf]); }, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]);
const handleDownloadMarkdownBundle = useCallback(async () => {
if (!threadId || !content) return;
setIsPackagingMarkdownBundle(true);
try {
const zip = new JSZip();
const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName;
const referencedTargets = collectMarkdownAssetTargets(content);
const refToVirtualPath = new Map<string, string>();
for (const ref of referencedTargets) {
const resolved = resolveReferencedVirtualPath(ref, filepath);
if (resolved) {
refToVirtualPath.set(ref, resolved);
}
}
const refToRelativeZipPath = new Map<string, string>();
const addedVirtualPaths = new Set<string>();
for (const [ref, virtualPath] of refToVirtualPath) {
const artifactEntryPath = toWorkspaceRelativePath(virtualPath);
if (!artifactEntryPath) continue;
const relativeFromMarkdown = toRelativePath(
dirnamePosix(markdownEntryPath),
artifactEntryPath,
);
refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath));
if (addedVirtualPaths.has(virtualPath)) continue;
addedVirtualPaths.add(virtualPath);
const response = await fetch(
urlOfArtifact({
filepath: virtualPath,
threadId,
}),
);
if (!response.ok) {
continue;
}
const data = await response.arrayBuffer();
zip.file(artifactEntryPath, data);
}
const rewrittenMarkdown = rewriteMarkdownLinksForBundle(
content,
refToRelativeZipPath,
);
zip.file(markdownEntryPath, rewrittenMarkdown);
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`;
const url = URL.createObjectURL(zipBlob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = zipName;
anchor.click();
URL.revokeObjectURL(url);
toast.success(t.common.exportSuccess);
} catch (error) {
console.error("Failed to package markdown bundle:", error);
toast.error("Failed to package markdown with referenced files.");
} finally {
setIsPackagingMarkdownBundle(false);
}
}, [threadId, content, filepath, fileName, t.common.exportSuccess]);
// 全屏切换处理 // 全屏切换处理
const handleFullscreenToggle = useCallback(() => { const handleFullscreenToggle = useCallback(() => {
@ -185,8 +286,8 @@ export function ArtifactFileDetail({
className, className,
)} )}
> >
<ArtifactHeader className=""> <ArtifactHeader className="grid grid-cols-12 gap-3">
<div className="flex items-center justify-start gap-2"> <div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
{previewable && ( {previewable && (
<ToggleGroup <ToggleGroup
type="single" type="single"
@ -245,10 +346,12 @@ export function ArtifactFileDetail({
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)} )}
{/* 放大缩小选择器 */} {/* 仅在代码视图显示缩放控制 */}
{isCodeFile && viewMode === "code" && (
<ArtifactZoomSelector value={zoom} onChange={setZoom} /> <ArtifactZoomSelector value={zoom} onChange={setZoom} />
)}
</div> </div>
<div className="flex min-w-0 grow items-center justify-center"> <div className="col-span-6 flex min-w-0 items-center justify-center px-1">
<ArtifactTitle> <ArtifactTitle>
{isWriteFile ? ( {isWriteFile ? (
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap"> <div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
@ -263,7 +366,7 @@ export function ArtifactFileDetail({
)} )}
</ArtifactTitle> </ArtifactTitle>
</div> </div>
<div className="flex items-center justify-end overflow-hidden"> <div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden">
<ArtifactActions> <ArtifactActions>
{isCodeFile && ( {isCodeFile && (
<ArtifactAction <ArtifactAction
@ -337,6 +440,20 @@ export function ArtifactFileDetail({
</ArtifactAction> </ArtifactAction>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]"> <DropdownMenuContent align="end" className="min-w-[160px]">
{language === "markdown" ? (
<DropdownMenuItem
onClick={handleDownloadMarkdownBundle}
disabled={isPackagingMarkdownBundle}
className="cursor-pointer"
>
{isPackagingMarkdownBundle ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{t.common.downloadOriginal}
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<a <a
href={urlOfArtifact({ href={urlOfArtifact({
@ -351,6 +468,7 @@ export function ArtifactFileDetail({
{t.common.downloadOriginal} {t.common.downloadOriginal}
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
)}
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */} {/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
{canConvertToDocxPdf && ( {canConvertToDocxPdf && (
<> <>
@ -475,6 +593,7 @@ export function ArtifactFileDetail({
language={language ?? "text"} language={language ?? "text"}
zoom={zoom} zoom={zoom}
threadId={threadId} threadId={threadId}
filepath={filepath}
/> />
)} )}
@ -489,6 +608,14 @@ export function ArtifactFileDetail({
</div> </div>
)} )}
{!isCodeFile && ( {!isCodeFile && (
isOfficePreviewKind(artifactPreviewKind) ? (
<ArtifactOfficePreview
className="h-full mb-[207px]"
kind={artifactPreviewKind}
artifactUrl={artifactUrl}
fileName={fileName}
/>
) : (
<PreviewIframe <PreviewIframe
className="size-full border-0" className="size-full border-0"
containerClassName="h-full mb-[207px]" containerClassName="h-full mb-[207px]"
@ -496,33 +623,159 @@ export function ArtifactFileDetail({
sandbox="allow-same-origin allow-scripts allow-downloads" sandbox="allow-same-origin allow-scripts allow-downloads"
title={`Artifact preview: ${fileName}`} title={`Artifact preview: ${fileName}`}
/> />
)
)} )}
</ArtifactContent> </ArtifactContent>
</Artifact> </Artifact>
); );
} }
const USER_DATA_PREFIX = "/mnt/user-data/";
function normalizePosixPath(path: string): string {
const isAbs = path.startsWith("/");
const parts = path.split("/").filter((part) => part.length > 0);
const stack: string[] = [];
for (const part of parts) {
if (part === ".") continue;
if (part === "..") {
if (stack.length > 0) stack.pop();
continue;
}
stack.push(part);
}
return `${isAbs ? "/" : ""}${stack.join("/")}`;
}
function dirnamePosix(path: string): string {
const normalized = normalizePosixPath(path);
const index = normalized.lastIndexOf("/");
if (index <= 0) return "";
return normalized.slice(0, index);
}
function toWorkspaceRelativePath(virtualPath: string): string | null {
const normalized = normalizePosixPath(virtualPath);
if (!normalized.startsWith(USER_DATA_PREFIX)) return null;
return normalized.slice(USER_DATA_PREFIX.length) || "artifact";
}
function toRelativePath(fromDir: string, targetPath: string): string {
const from = normalizePosixPath(fromDir).split("/").filter(Boolean);
const to = normalizePosixPath(targetPath).split("/").filter(Boolean);
let i = 0;
while (i < from.length && i < to.length && from[i] === to[i]) {
i += 1;
}
const up = new Array(from.length - i).fill("..");
const down = to.slice(i);
return [...up, ...down].join("/") || ".";
}
function normalizeReference(ref: string): string {
const trimmed = ref.trim().replace(/^<|>$/g, "");
return trimmed.split(/[ \t]/)[0] ?? "";
}
function isExternalReference(ref: string): boolean {
return (
!ref ||
ref.startsWith("#") ||
ref.startsWith("//") ||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref)
);
}
function resolveReferencedVirtualPath(
ref: string,
baseFilepath: string,
): string | null {
const normalizedRef = normalizeReference(ref);
if (isExternalReference(normalizedRef)) return null;
let withoutHash = normalizedRef.split("#")[0] ?? normalizedRef;
withoutHash = withoutHash.split("?")[0] ?? withoutHash;
if (!withoutHash) return null;
if (withoutHash.startsWith("/mnt/user-data/")) {
return normalizePosixPath(withoutHash);
}
if (withoutHash.startsWith("mnt/user-data/")) {
return normalizePosixPath(`/${withoutHash}`);
}
if (withoutHash.startsWith("/")) {
return null;
}
const baseDir = dirnamePosix(baseFilepath);
const combined = normalizePosixPath(`${baseDir}/${withoutHash}`);
if (!combined.startsWith(USER_DATA_PREFIX)) return null;
return combined;
}
function collectMarkdownAssetTargets(markdown: string): Set<string> {
const targets = new Set<string>();
const markdownRefRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
const htmlAttrRegex = /<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*(["'])([^"']+)\1/gi;
for (const match of markdown.matchAll(markdownRefRegex)) {
const raw = match[1]?.trim();
if (raw) targets.add(raw);
}
for (const match of markdown.matchAll(htmlAttrRegex)) {
const raw = match[2]?.trim();
if (raw) targets.add(raw);
}
return targets;
}
function rewriteMarkdownLinksForBundle(
markdown: string,
refToRelativeZipPath: Map<string, string>,
): string {
const rewriteTarget = (rawTarget: string): string => {
const normalized = normalizeReference(rawTarget);
return refToRelativeZipPath.get(normalized) ?? rawTarget;
};
const markdownRewritten = markdown.replace(
/(!?\[[^\]]*\]\()([^)]+)(\))/g,
(_full, prefix, target, suffix) => {
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
},
);
return markdownRewritten.replace(
/(<(?:img|a)\b[^>]*\b(?:src|href)\s*=\s*["'])([^"']+)(["'])/gi,
(_full, prefix, target, suffix) => {
return `${prefix}${rewriteTarget(String(target))}${suffix}`;
},
);
}
export function ArtifactFilePreview({ export function ArtifactFilePreview({
content, content,
language, language,
zoom = 100, zoom = 100,
threadId, threadId,
filepath,
}: { }: {
content: string; content: string;
language: string; language: string;
zoom?: number; zoom?: number;
threadId: string; threadId: string;
filepath?: string;
}) { }) {
const zoomScale = zoom / 100; const zoomScale = zoom / 100;
const normalizedContent = useMemo(() => { const normalizedContent = useMemo(() => {
return rewriteArtifactImagePaths(content ?? "", threadId); return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
}, [content, threadId]); }, [content, threadId, filepath]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div <div
className={cn("w-full bg-white mb-[207px] p-[20px]")} className={cn("w-full bg-white mb-[207px] p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties} style={{ "--zoom-scale": zoomScale } as CSSProperties}
> >
<Streamdown <Streamdown
className="w-full" className="w-full"
@ -588,8 +841,276 @@ function PreviewIframe({
); );
} }
function rewriteArtifactImagePaths(content: string, threadId?: string) { function ArtifactOfficePreview({
if (!threadId || !/\/?mnt\/user-data\//.test(content)) { className,
kind,
artifactUrl,
fileName,
}: {
className?: string;
kind: ArtifactPreviewKind;
artifactUrl: string;
fileName: string;
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [xlsxHtml, setXlsxHtml] = useState<string>("");
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>("");
const docxContainerRef = useRef<HTMLDivElement | null>(null);
const pptxContainerRef = useRef<HTMLDivElement | null>(null);
const workbookRef = useRef<XLSX.WorkBook | null>(null);
const canRenderDocx = kind === "docx";
const canRenderXlsx = kind === "xlsx";
const canRenderPptx = kind === "pptx";
useEffect(() => {
let disposed = false;
async function renderDocx() {
if (!canRenderDocx || !artifactUrl || !docxContainerRef.current) {
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const { renderAsync } = await import("docx-preview");
if (disposed || !docxContainerRef.current) {
return;
}
docxContainerRef.current.innerHTML = "";
await renderAsync(blob, docxContainerRef.current, undefined, {
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
inWrapper: true,
});
} catch (err) {
console.error("Failed to render docx preview:", err);
if (!disposed) {
setError("无法预览该 DOCX 文件。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderDocx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderDocx]);
useEffect(() => {
let disposed = false;
async function parseXlsx() {
if (!canRenderXlsx || !artifactUrl) {
return;
}
setIsLoading(true);
setError(null);
workbookRef.current = null;
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const workbook = XLSX.read(bytes, { type: "array" });
workbookRef.current = workbook;
const names = workbook.SheetNames ?? [];
if (names.length === 0) {
throw new Error("Empty workbook");
}
if (disposed) return;
setSheetNames(names);
const first = names[0] ?? "";
setActiveSheet(first);
const sheet = workbook.Sheets[first];
const html = sheet
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
: "";
setXlsxHtml(html);
} catch (err) {
console.error("Failed to render xlsx preview:", err);
if (!disposed) {
setError("无法预览该 Excel 文件。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void parseXlsx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderXlsx]);
useEffect(() => {
if (!canRenderXlsx || !activeSheet || !workbookRef.current) {
return;
}
try {
const sheet = workbookRef.current.Sheets[activeSheet];
if (!sheet) return;
setXlsxHtml(XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" }));
} catch (err) {
console.error("Failed to switch xlsx sheet:", err);
setError("切换工作表失败。");
}
}, [activeSheet, canRenderXlsx]);
useEffect(() => {
let disposed = false;
type PptxPreviewModule = {
init: (
container: HTMLElement,
options: { width: number; height: number },
) => {
preview: (buffer: ArrayBuffer) => Promise<void> | void;
};
};
async function renderPptx() {
if (!canRenderPptx || !artifactUrl || !pptxContainerRef.current) {
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const pptxModule = (await import("pptx-preview")) as unknown as PptxPreviewModule;
if (disposed || !pptxContainerRef.current) {
return;
}
const container = pptxContainerRef.current;
container.innerHTML = "";
const previewer = pptxModule.init(container, { width: 960, height: 540 });
await Promise.resolve(previewer.preview(bytes));
} catch (err) {
console.error("Failed to render pptx preview:", err);
if (!disposed) {
setError("无法预览该 PPT 文件。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderPptx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderPptx]);
return (
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
{canRenderXlsx && sheetNames.length > 0 && (
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
{sheetNames.map((sheetName) => (
<button
key={sheetName}
type="button"
className={cn(
"rounded px-2 py-1 text-xs whitespace-nowrap",
activeSheet === sheetName
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveSheet(sheetName)}
>
{sheetName}
</button>
))}
</div>
)}
<div className="h-full overflow-auto p-4">
{canRenderDocx && (
<div
ref={docxContainerRef}
className="docx-preview-wrap mx-auto max-w-5xl"
/>
)}
{canRenderXlsx && xlsxHtml && (
<div
className="artifact-xlsx-preview overflow-auto"
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
/>
)}
{canRenderPptx && (
<div
ref={pptxContainerRef}
className="pptx-preview-wrap mx-auto w-full overflow-auto"
/>
)}
</div>
{error && (
<ArtifactPreviewFallback
fileName={fileName}
artifactUrl={artifactUrl}
message={error}
/>
)}
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactPreviewFallback({
message,
fileName,
artifactUrl,
}: {
message: string;
fileName: string;
artifactUrl: string;
}) {
return (
<div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center">
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
<p className="text-muted-foreground mb-3 text-xs">{message}</p>
<a
className="text-primary text-sm font-medium underline"
href={artifactUrl}
target="_blank"
rel="noreferrer"
>
</a>
</div>
);
}
function rewriteArtifactImagePaths(
content: string,
threadId?: string,
baseFilepath?: string,
) {
if (!threadId) {
return content; return content;
} }
@ -597,6 +1118,20 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`; const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
return resolveArtifactURL(normalizedPath, threadId); return resolveArtifactURL(normalizedPath, threadId);
}; };
const toArtifactUrlFromRelative = (rawPath: string) => {
const trimmed = rawPath.trim();
if (!baseFilepath || !trimmed) return null;
if (trimmed.startsWith("/") || trimmed.startsWith("//")) return null;
if (trimmed.startsWith("#")) return null;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return null;
const baseDir = baseFilepath.replace(/[^/]*$/, "");
if (!baseDir.startsWith("/")) return null;
const absolutePath = new URL(trimmed, `file://${baseDir}`).pathname;
if (!absolutePath.startsWith("/mnt/user-data/")) return null;
return resolveArtifactURL(absolutePath, threadId);
};
const markdownRewritten = content.replace( const markdownRewritten = content.replace(
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g, /!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
@ -604,8 +1139,18 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
return `![${alt}](${toArtifactUrl(rawPath)})`; return `![${alt}](${toArtifactUrl(rawPath)})`;
}, },
); );
const markdownRelativeRewritten = markdownRewritten.replace(
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
(_full, alt, rawPath) => {
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
if (!absoluteUrl) {
return `![${alt}](${rawPath})`;
}
return `![${alt}](${absoluteUrl})`;
},
);
const shorthandMarkdownRewritten = markdownRewritten.replace( const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
/!(?!\[)([^\n()]+?)\s*[(]\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*[)]/g, /!(?!\[)([^\n()]+?)\s*[(]\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*[)]/g,
(_full, alt, rawPath) => { (_full, alt, rawPath) => {
return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`; return `![${String(alt).trim()}](${toArtifactUrl(rawPath)})`;
@ -613,9 +1158,16 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
); );
return shorthandMarkdownRewritten.replace( return shorthandMarkdownRewritten.replace(
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi, /(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
(_full, prefix, quote, rawPath) => { (_full, prefix, quote, rawPath) => {
if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) {
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`; return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
}
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
if (absoluteUrl) {
return `${prefix}${quote}${absoluteUrl}${quote}`;
}
return `${prefix}${quote}${rawPath}${quote}`;
}, },
); );
} }
@ -626,6 +1178,9 @@ type ArtifactPreviewKind =
| "video" | "video"
| "audio" | "audio"
| "pdf" | "pdf"
| "docx"
| "xlsx"
| "pptx"
| "other"; | "other";
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind { function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
@ -636,9 +1191,22 @@ function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video"; if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video";
if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio"; if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio";
if (lower.endsWith(".pdf")) return "pdf"; if (lower.endsWith(".pdf")) return "pdf";
if (/\.(docx?)$/.test(lower)) return "docx";
if (/\.(xlsx?)$/.test(lower)) return "xlsx";
if (/\.(pptx?)$/.test(lower)) return "pptx";
return "other"; return "other";
} }
const OFFICE_PREVIEW_KINDS = new Set<ArtifactPreviewKind>([
"docx",
"xlsx",
"pptx",
]);
function isOfficePreviewKind(kind: ArtifactPreviewKind) {
return OFFICE_PREVIEW_KINDS.has(kind);
}
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")

View File

@ -37,11 +37,12 @@ interface ArtifactsProviderProps {
export function ArtifactsProvider({ children }: ArtifactsProviderProps) { export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]); const [artifacts, setArtifacts] = useState<string[]>([]);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null); const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true); const [autoSelect, setAutoSelect] = useState(false);
const [open, setOpen] = useState( const [open, setOpen] = useState(false);
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", // const [open, setOpen] = useState(
); // env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
const [autoOpen, setAutoOpen] = useState(true); // );
const [autoOpen, setAutoOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();

View File

@ -3,6 +3,7 @@ import { useCallback, useState, type ComponentProps } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { copyToClipboard } from "@/lib/utils";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@ -14,10 +15,14 @@ export function CopyButton({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => { const handleCopy = useCallback(async () => {
void navigator.clipboard.writeText(clipboardData); try {
await copyToClipboard(clipboardData);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch {
// no-op: caller controls error UI if needed
}
}, [clipboardData]); }, [clipboardData]);
return ( return (
<Tooltip content={t.clipboard.copyToClipboard}> <Tooltip content={t.clipboard.copyToClipboard}>

View File

@ -28,6 +28,7 @@ export function IframeTestPanel() {
const iframeSkill = useIframeSkill(); const iframeSkill = useIframeSkill();
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([]);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>( const [position, setPosition] = useState<{ x: number; y: number } | null>(
null, null,
); );
@ -168,15 +169,25 @@ export function IframeTestPanel() {
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
> >
<span className="text-xs font-bold text-white">🧪 iframe </span> <span className="text-xs font-bold text-white">🧪 iframe </span>
<div className="flex items-center gap-2">
<button <button
className="text-white/70 hover:text-white" className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setCollapsed((prev) => !prev)}
>
{collapsed ? "▢" : "—"}
</button>
<button
className="text-white/70 hover:text-white"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
</button> </button>
</div> </div>
</div>
<div className="space-y-3 p-3"> {!collapsed && <div className="space-y-3 p-3">
{/* 当前状态 */} {/* 当前状态 */}
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs"> <div className="rounded-lg bg-gray-50 px-3 py-2 text-xs">
<div className="mb-1 font-semibold text-gray-500"></div> <div className="mb-1 font-semibold text-gray-500"></div>
@ -291,6 +302,48 @@ export function IframeTestPanel() {
> >
selectedSkill selectedSkill
</Button> </Button>
<Button
size="sm"
className="w-full bg-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: [
{ id: "5", name: "文档处理" },
{ id: "1216", name: "市场研究报告" },
{ id: "1245", name: "市场研究报告" },
{ id: "520", name: "市场研究报告" },
{ id: "409", name: "市场研究报告" },
],
},
"*",
);
addLog(
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
);
}}
>
📦 selectedSkills message
</Button>
<Button
size="sm"
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: [],
},
"*",
);
addLog("模拟宿主页 → selectedSkills []");
}}
>
🧹 selectedSkills
</Button>
<Button <Button
size="sm" size="sm"
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100" className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
@ -385,7 +438,7 @@ export function IframeTestPanel() {
))} ))}
</div> </div>
)} )}
</div> </div>}
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import {
CheckIcon, CheckIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
Loader2Icon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
@ -40,7 +41,6 @@ import {
usePromptInputController, usePromptInputController,
type PromptInputMessage, type PromptInputMessage,
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button"; import { ConfettiButton } from "@/components/ui/confetti-button";
import { import {
@ -56,13 +56,13 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tag } from "@/components/ui/tag";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { import type {
SelectedSkillPayloadItem, SelectedSkillPayloadItem,
} from "@/core/i18n/locales/types"; } from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -130,7 +130,8 @@ export function InputBox({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill(); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
const threadId = threadIdFromProps; const threadId = threadIdFromProps;
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
@ -326,7 +327,7 @@ export function InputBox({
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!", hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
effectiveIsFocused ? "h-[200px]" : "h-[80px]", effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)} )}
disabled={disabled} disabled={isInputDisabled}
globalDrop globalDrop
multiple multiple
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -341,7 +342,7 @@ export function InputBox({
"size-full", "size-full",
!effectiveIsFocused && "h-[80px] py-0 leading-20", !effectiveIsFocused && "h-[80px] py-0 leading-20",
)} )}
disabled={disabled} disabled={isInputDisabled}
placeholder={t.inputBox.placeholder} placeholder={t.inputBox.placeholder}
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={initialValue} defaultValue={initialValue}
@ -364,7 +365,7 @@ export function InputBox({
"pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0", "pointer-events-none invisible h-[0px] translate-y-2 p-[0px] opacity-0",
)} )}
> >
<PromptInputTools> <PromptInputTools className="min-w-0 flex-1">
{/* TODO: Add more connectors here {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className="px-2!" /> <PromptInputActionMenuTrigger className="px-2!" />
@ -377,7 +378,8 @@ export function InputBox({
<AddAttachmentsButton className="px-2!" /> <AddAttachmentsButton className="px-2!" />
<IframeSkillDialogButton <IframeSkillDialogButton
className="px-2!" className="px-2!"
selectedSkill={iframeSkill.selectedSkill} selectedSkills={iframeSkill.selectedSkills}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog} openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill} clearSkill={iframeSkill.clearSkill}
/> />
@ -421,7 +423,7 @@ export function InputBox({
</PromptInputFooter> </PromptInputFooter>
<PromptInputSubmit <PromptInputSubmit
className="absolute right-3 bottom-5 z-[20] border-0" className="absolute right-3 bottom-5 z-[20] border-0"
disabled={disabled} disabled={isInputDisabled}
variant="outline" variant="outline"
status={status} status={status}
/> />
@ -429,8 +431,8 @@ export function InputBox({
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && ( {showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
threadId={threadId} bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
sendSelectSkill={iframeSkill.sendSelectSkill} isBootstrapping={iframeSkill.isBootstrapping}
/> />
)} )}
@ -494,29 +496,37 @@ export function InputBox({
// SuggestionList 容器 // SuggestionList 容器
function SuggestionListContainer({ function SuggestionListContainer({
threadId, bootstrapAndLockSkills,
sendSelectSkill, isBootstrapping,
}: { }: {
threadId: string; bootstrapAndLockSkills: (params: {
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) { }) {
return ( return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4"> <div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<SuggestionList threadId={threadId} sendSelectSkill={sendSelectSkill} /> <SuggestionList
bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping}
/>
</div> </div>
); );
} }
// 快速选择skillbutton // 快速选择skillbutton
function SuggestionList({ function SuggestionList({
threadId, bootstrapAndLockSkills,
sendSelectSkill, isBootstrapping,
}: { }: {
threadId: string; bootstrapAndLockSkills: (params: {
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const suggestions = t.inputBox.suggestions; const suggestions = t.inputBox.suggestions;
const promptSuggestions = suggestions.filter( const promptSuggestions = suggestions.filter(
@ -535,50 +545,32 @@ function SuggestionList({
suggestion: string; suggestion: string;
}, },
) => { ) => {
const languageTypeRaw = if (isBootstrapping) return;
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const bootstrapByIds = (ids: string[]) => {
const content_ids = Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (!threadId || content_ids.length === 0) return;
void bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
};
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页 // 优先使用 children 中的 skill保留每个 skill 自己的 name用于 tag 展示)
const childSkillIds = (suggestion.children ?? []) const childSkills = (suggestion.children ?? [])
.map((item) => String(item.id).trim()) .map((item) => ({
.filter((id): id is string => Boolean(id)); id: String(item.id).trim(),
if (childSkillIds.length > 0) { name: item.name?.trim() ?? "",
sendSelectSkill( }))
childSkillIds.map((id) => ({ .filter((item): item is { id: string; name: string } =>
id, Boolean(item.id) && Boolean(item.name),
name: suggestion.suggestion,
})),
); );
bootstrapByIds(childSkillIds); if (childSkills.length > 0) {
void bootstrapAndLockSkills({
selectedSkills: childSkills,
title: suggestion.suggestion,
});
return; return;
} }
if (suggestion.skill_id && suggestion.skill_id.length > 0) { if (suggestion.skill_id && suggestion.skill_id.length > 0) {
sendSelectSkill( void bootstrapAndLockSkills({
suggestion.skill_id.map((id) => ({ selectedSkills: suggestion.skill_id.map((id) => ({
id, id,
name: suggestion.suggestion, name: suggestion.suggestion,
})), })),
); title: suggestion.suggestion,
bootstrapByIds(suggestion.skill_id); });
return; return;
} }
// 原有逻辑 // 原有逻辑
@ -598,7 +590,7 @@ function SuggestionList({
} }
}, 500); }, 500);
}, },
[textInput, sendSelectSkill, threadId, searchParams], [bootstrapAndLockSkills, isBootstrapping, textInput],
); );
return ( return (
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions"> <Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
@ -647,22 +639,24 @@ function AddAttachmentsButton({ className }: { className?: string }) {
// 启动iframeSkillDialog // 启动iframeSkillDialog
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
selectedSkill, selectedSkills,
isBootstrapping,
openSkillDialog, openSkillDialog,
clearSkill, clearSkill,
}: { }: {
className?: string; className?: string;
selectedSkill: { skill_id: string; title: string } | null; selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean;
openSkillDialog: () => void; openSkillDialog: () => void;
clearSkill: () => void; clearSkill: (skillId?: string) => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<Tooltip content={t.inputBox.selectSkill}> <Tooltip content={t.inputBox.selectSkill}>
<PromptInputButton <PromptInputButton
className={cn("group px-2! hover:bg-[#EAE2F5]", className)} className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
onClick={openSkillDialog} onClick={openSkillDialog}
> >
<svg <svg
@ -678,20 +672,35 @@ function IframeSkillDialogButton({
</svg> </svg>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
{selectedSkill && ( {isBootstrapping ? (
<Badge <Tag className="bg-background text-muted-foreground gap-2 border">
variant="secondary" <Loader2Icon className="size-3 animate-spin" />
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]" {t.common.loading}
</Tag>
) : null}
{!isBootstrapping && selectedSkills.length > 0 ? (
<div
className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onWheel={(event) => {
if (event.deltaY === 0) return;
event.currentTarget.scrollLeft += event.deltaY;
}}
> >
{selectedSkill.title} {selectedSkills.map((skill, index) => (
<Tag key={`${skill.skill_id}-${skill.title}-${index}`} className="shrink-0">
{skill.title}
{/* TODO: 因为后端接口不支持取消选择skill所以暂时禁用取消选择按钮 */}
<button <button
onClick={clearSkill} onClick={() => clearSkill(skill.skill_id)}
className="hover:bg-muted-foreground/20 ml-1 rounded-full" className="hover:bg-muted-foreground/20 ml-1 rounded-full"
type="button"
> >
<XIcon className="size-3" /> <XIcon className="size-3" />
</button> </button>
</Badge> </Tag>
)} ))}
</div>
) : null}
</div> </div>
); );
} }

View File

@ -13,6 +13,7 @@ import {
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { BundledLanguage } from "shiki";
import { import {
ChainOfThought, ChainOfThought,
@ -39,6 +40,8 @@ import { Tooltip } from "../tooltip";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
export function MessageGroup({ export function MessageGroup({
className, className,
messages, messages,
@ -76,6 +79,9 @@ export function MessageGroup({
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const totalToolStepCount = aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
const shouldShowToolSteps = !!lastToolCallStep &&
(showAbove || aboveLastToolCallSteps.length === 0);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return ( return (
<ChainOfThought <ChainOfThought
@ -87,14 +93,17 @@ export function MessageGroup({
key="above" key="above"
className="w-full items-start justify-start text-left" className="w-full items-start justify-start text-left"
variant="ghost" variant="ghost"
onClick={() => setShowAbove(!showAbove)} onClick={(event) => {
event.stopPropagation();
setShowAbove((prev) => !prev);
}}
> >
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<span className="opacity-60"> <span className="opacity-60">
{showAbove {showAbove
? t.toolCalls.lessSteps ? t.toolCalls.lessSteps
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)} : t.toolCalls.moreSteps(totalToolStepCount)}
</span> </span>
} }
icon={ icon={
@ -108,7 +117,7 @@ export function MessageGroup({
></ChainOfThoughtStep> ></ChainOfThoughtStep>
</Button> </Button>
)} )}
{lastToolCallStep && ( {shouldShowToolSteps && (
<ChainOfThoughtContent className="px-4 pb-2"> <ChainOfThoughtContent className="px-4 pb-2">
{showAbove && {showAbove &&
aboveLastToolCallSteps.map((step) => aboveLastToolCallSteps.map((step) =>
@ -145,7 +154,10 @@ export function MessageGroup({
key={lastReasoningStep.id} key={lastReasoningStep.id}
className="w-full items-start justify-start text-left" className="w-full items-start justify-start text-left"
variant="ghost" variant="ghost"
onClick={() => setShowLastThinking(!showLastThinking)} onClick={(event) => {
event.stopPropagation();
setShowLastThinking((prev) => !prev);
}}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<ChainOfThoughtStep <ChainOfThoughtStep
@ -203,6 +215,33 @@ function ToolCall({
const { t } = useI18n(); const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts(); useArtifacts();
const [isCommandExpanded, setIsCommandExpanded] = useState(false);
const ExpandableToolContent = ({
content,
language = "bash",
expanded = false,
}: {
content: string;
language?: BundledLanguage;
expanded?: boolean;
}) => {
const shouldCollapse = content.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
const shouldShowCodeBlock = !shouldCollapse || expanded;
return (
<div className="space-y-1">
{shouldShowCodeBlock && (
<CodeBlock
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language={language}
code={content}
/>
)}
</div>
);
};
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@ -377,19 +416,31 @@ function ToolCall({
return t.toolCalls.executeCommand; return t.toolCalls.executeCommand;
} }
const command: string | undefined = (args as { command: string })?.command; const command: string | undefined = (args as { command: string })?.command;
const shouldCollapse = !!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
label={description} label={description}
icon={SquareTerminalIcon} icon={SquareTerminalIcon}
action={shouldCollapse
? (
<Button
className="h-7 px-3 text-xs"
variant="ghost"
onClick={(event) => {
event.stopPropagation();
setIsCommandExpanded((prev) => !prev);
}}
>
{isCommandExpanded
? t.toolCalls.collapseContent
: t.toolCalls.expandContent}
</Button>
)
: undefined}
> >
{command && ( {command && (
<CodeBlock <ExpandableToolContent content={command} expanded={isCommandExpanded} />
className="mx-0 cursor-pointer border-none px-0"
showLineNumbers={false}
language="bash"
code={command}
/>
)} )}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );

View File

@ -56,6 +56,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { copyToClipboard } from "@/lib/utils";
export function RecentChatList() { export function RecentChatList() {
const { t } = useI18n(); const { t } = useI18n();
@ -119,7 +120,7 @@ export function RecentChatList() {
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`; const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
try { try {
await navigator.clipboard.writeText(shareUrl); await copyToClipboard(shareUrl);
toast.success(t.clipboard.linkCopied); toast.success(t.clipboard.linkCopied);
} catch { } catch {
toast.error(t.clipboard.failedToCopyToClipboard); toast.error(t.clipboard.failedToCopyToClipboard);
@ -178,7 +179,7 @@ export function RecentChatList() {
<div> <div>
<Link <Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread.thread_id)} href={`${pathOfThread(thread.thread_id)}?is_chatting=true`}
> >
{titleOfThread(thread)} {titleOfThread(thread)}
</Link> </Link>

View File

@ -42,7 +42,8 @@ export function WorkspaceHeader({ className }: { className?: string }) {
</Link> </Link>
) : ( ) : (
<div className="text-primary ml-2 cursor-default font-serif"> <div className="text-primary ml-2 cursor-default font-serif">
XClaw {/* TODO: 测试标识 */}
XClaw <span className="text-sm text-[#000000c5]">v3.2.1 fix(frontend): /new预创建会话并强制跳转聊天态</span>
</div> </div>
)} )}
<SidebarTrigger /> <SidebarTrigger />

View File

@ -277,6 +277,8 @@ export const enUS: Translations = {
writeFile: "Write file", writeFile: "Write file",
clickToViewContent: "Click to view file content", clickToViewContent: "Click to view file content",
writeTodos: "Update to-do list", writeTodos: "Update to-do list",
expandContent: "Expand",
collapseContent: "Collapse",
skillInstallTooltip: "Install skill and make it available to DeerFlow", skillInstallTooltip: "Install skill and make it available to DeerFlow",
}, },

View File

@ -208,6 +208,8 @@ export interface Translations {
writeFile: string; writeFile: string;
clickToViewContent: string; clickToViewContent: string;
writeTodos: string; writeTodos: string;
expandContent: string;
collapseContent: string;
skillInstallTooltip: string; skillInstallTooltip: string;
}; };

View File

@ -12,6 +12,7 @@ import {
import type { Translations } from "./types"; import type { Translations } from "./types";
export const zhCN: Translations = { export const zhCN: Translations = {
// 隐蔽版本标识Tagv3.2.1 feat: 宿主页复制
// Locale meta // Locale meta
locale: { locale: {
localName: "中文", localName: "中文",
@ -57,8 +58,7 @@ export const zhCN: Translations = {
// Welcome // Welcome
welcome: { welcome: {
// TODO: 测试环境标识 greeting: "轻办公 · XClaw",
greeting: "轻办公 · XClaw Tagv3.2.0 --- Skill功能施工中 --- refactor(frontend): 将 SELECT_SKILL 重命名为 SELECT_SKILLS.",
description: description:
"欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。", "欢迎使用 🦌 DeerFlow一个完全开源的超级智能体。通过内置和自定义的 Skills\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等几乎可以做任何事情。",
@ -265,6 +265,8 @@ export const zhCN: Translations = {
writeFile: "写入文件", writeFile: "写入文件",
clickToViewContent: "点击查看文件内容", clickToViewContent: "点击查看文件内容",
writeTodos: "更新 To-do 列表", writeTodos: "更新 To-do 列表",
expandContent: "展开",
collapseContent: "收起",
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用", skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
}, },

View File

@ -11,6 +11,8 @@ export const POST_MESSAGE_TYPES = {
FULLSCREEN: "fullscreen", FULLSCREEN: "fullscreen",
// 会话是否处于聊天态 // 会话是否处于聊天态
IS_CHATTING: "isChatting", IS_CHATTING: "isChatting",
// 请求宿主页执行复制
COPY_TO_CLIPBOARD: "copyToClipboard",
// 选择预定义 skill // 选择预定义 skill
SELECT_SKILLS: "selectedSkills", SELECT_SKILLS: "selectedSkills",
// 打开 skill 选择对话框 // 打开 skill 选择对话框
@ -21,6 +23,8 @@ export const POST_MESSAGE_TYPES = {
export const RECEIVE_MESSAGE_TYPES = { export const RECEIVE_MESSAGE_TYPES = {
// 选中的 skill 数据 // 选中的 skill 数据
SELECTED_SKILL: "selectedSkill", SELECTED_SKILL: "selectedSkill",
// 选中的 skills 数据(数组)
SELECTED_SKILLS: "selectedSkills",
} as const; } as const;
// 消息类型 // 消息类型
@ -40,6 +44,11 @@ export interface IsChattingMessage {
isChatting: boolean; isChatting: boolean;
} }
export interface CopyToClipboardMessage {
type: typeof POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD;
text: string;
}
export interface SelectSkillMessage { export interface SelectSkillMessage {
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS; type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
selectedSkills: SelectedSkillPayloadItem[]; selectedSkills: SelectedSkillPayloadItem[];
@ -80,11 +89,31 @@ export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMe
return isValidId && typeof title === "string" && title.trim().length > 0; return isValidId && typeof title === "string" && title.trim().length > 0;
} }
export function isSelectedSkillsMessage(value: unknown): value is SelectSkillMessage {
const record = asRecord(value);
if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) {
return false;
}
const selectedSkills = record.selectedSkills;
if (!Array.isArray(selectedSkills)) {
return false;
}
return selectedSkills.every((item) => {
const skill = asRecord(item);
if (!skill) return false;
const id = skill.id;
const name = skill.name;
const isValidId = typeof id === "string" || typeof id === "number";
return isValidId && typeof name === "string" && name.trim().length > 0;
});
}
// 发送消息的辅助函数 // 发送消息的辅助函数
export function sendToParent( export function sendToParent(
message: message:
| FullscreenMessage | FullscreenMessage
| IsChattingMessage | IsChattingMessage
| CopyToClipboardMessage
| SelectSkillMessage | SelectSkillMessage
| OpenSkillDialogMessage, | OpenSkillDialogMessage,
): void { ): void {

View File

@ -62,5 +62,9 @@ export function getLocalSettings(): LocalSettings {
} }
export function saveLocalSettings(settings: LocalSettings) { export function saveLocalSettings(settings: LocalSettings) {
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings)); void settings;
// 注释了,因为本地存储会污染模型配置
console.log("localStorage设置已经注释");
localStorage.removeItem(LOCAL_SETTINGS_KEY);
// localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
} }

View File

@ -46,27 +46,83 @@ export type LegacyThreadStreamOptions = {
useSubmitThread?: boolean; useSubmitThread?: boolean;
}; };
const STREAM_ERROR_FALLBACK_MESSAGE = "Request failed.";
const STREAM_ERROR_TOAST_MESSAGE = "出现了某些错误。";
const STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS = 2000;
function readMessageCandidate(value: unknown): string | null {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (value instanceof Error && value.message.trim()) {
return value.message.trim();
}
return null;
}
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { const directMessage = readMessageCandidate(error);
return error; if (directMessage) {
return directMessage;
} }
if (error instanceof Error && error.message.trim()) {
return error.message; const visited = new Set<object>();
const queue: unknown[] = [error];
const preferredKeys = ["message", "detail", "error"];
while (queue.length > 0) {
const current = queue.shift();
if (current == null) {
continue;
} }
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message"); const message = readMessageCandidate(current);
if (typeof message === "string" && message.trim()) { if (message) {
return message; return message;
} }
const nestedError = Reflect.get(error, "error");
if (nestedError instanceof Error && nestedError.message.trim()) { if (typeof current !== "object") {
return nestedError.message; continue;
} }
if (typeof nestedError === "string" && nestedError.trim()) {
return nestedError; if (visited.has(current)) {
continue;
}
visited.add(current);
for (const key of preferredKeys) {
const candidate = Reflect.get(current, key);
const parsed = readMessageCandidate(candidate);
if (parsed) {
return parsed;
}
if (candidate && typeof candidate === "object") {
queue.push(candidate);
} }
} }
return "Request failed.";
if (Array.isArray(current)) {
queue.push(...current);
continue;
}
for (const value of Object.values(current)) {
if (value && typeof value === "object") {
queue.push(value);
}
}
}
return STREAM_ERROR_FALLBACK_MESSAGE;
}
function normalizeThreadId(
value: string | null | undefined,
): string | undefined {
if (!value) return undefined;
const normalized = value.trim();
if (!normalized || normalized === "new") return undefined;
return normalized;
} }
export function useThreadStreamLegacy({ export function useThreadStreamLegacy({
@ -142,6 +198,9 @@ export function useThreadStream({
// and to allow access to the current thread id in onUpdateEvent // and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null); const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false); const startedRef = useRef(false);
const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>(
null,
);
const listeners = useRef({ const listeners = useRef({
onStart, onStart,
@ -155,12 +214,14 @@ export function useThreadStream({
}, [onStart, onFinish, onToolEnd]); }, [onStart, onFinish, onToolEnd]);
useEffect(() => { useEffect(() => {
const normalizedThreadId = threadId ?? null; const normalizedThreadId = normalizeThreadId(threadId) ?? null;
if (!normalizedThreadId) { if (!normalizedThreadId) {
// Just reset for new thread creation when threadId becomes null/undefined // Just reset for new thread creation when threadId becomes null/undefined
startedRef.current = false; startedRef.current = false;
setOnStreamThreadId(normalizedThreadId);
} }
setOnStreamThreadId((prev) =>
prev === normalizedThreadId ? prev : normalizedThreadId,
);
threadIdRef.current = normalizedThreadId; threadIdRef.current = normalizedThreadId;
}, [threadId]); }, [threadId]);
@ -171,6 +232,23 @@ export function useThreadStream({
} }
}, []); }, []);
const showStreamErrorToast = useCallback((error: unknown) => {
const message = getStreamErrorMessage(error);
const now = Date.now();
const lastToast = lastErrorToastRef.current;
if (
lastToast &&
lastToast.message === message &&
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
) {
return;
}
lastErrorToastRef.current = { message, timestamp: now };
console.error("[useThreadStream] conversation stream error:", error);
console.error("[useThreadStream] parsed error message:", message);
toast.error(STREAM_ERROR_TOAST_MESSAGE);
}, []);
const handleStreamStart = useCallback( const handleStreamStart = useCallback(
(_threadId: string) => { (_threadId: string) => {
threadIdRef.current = _threadId; threadIdRef.current = _threadId;
@ -250,7 +328,7 @@ export function useThreadStream({
}, },
onError(error) { onError(error) {
setOptimisticMessages([]); setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error)); showStreamErrorToast(error);
}, },
onFinish(state) { onFinish(state) {
listeners.current.onFinish?.(state.values); listeners.current.onFinish?.(state.values);
@ -275,6 +353,13 @@ export function useThreadStream({
} }
}, [thread.messages.length, optimisticMessages.length]); }, [thread.messages.length, optimisticMessages.length]);
useEffect(() => {
if (!thread.error) {
return;
}
showStreamErrorToast(thread.error);
}, [thread.error, showStreamErrorToast]);
const sendMessage = useCallback( const sendMessage = useCallback(
async ( async (
threadId: string | undefined, threadId: string | undefined,
@ -288,7 +373,9 @@ export function useThreadStream({
const text = message.text.trim(); const text = message.text.trim();
const resolvedThreadId = const resolvedThreadId =
threadId ?? threadIdRef.current ?? undefined; normalizeThreadId(threadId) ??
normalizeThreadId(threadIdRef.current) ??
undefined;
if (resolvedThreadId === "new") { if (resolvedThreadId === "new") {
toast.error("Invalid thread id 'new'. Please refresh and retry."); toast.error("Invalid thread id 'new'. Please refresh and retry.");
sendInFlightRef.current = false; sendInFlightRef.current = false;

View File

@ -4,6 +4,8 @@ import {
Paragraph, Paragraph,
TextRun, TextRun,
HeadingLevel, HeadingLevel,
ImageRun,
type ParagraphChild,
} from "docx"; } from "docx";
import { marked } from "marked"; import { marked } from "marked";
@ -57,6 +59,10 @@ export interface DocxOptions {
* @default 22 (11pt) * @default 22 (11pt)
*/ */
codeFontSize?: number; codeFontSize?: number;
/**
* Markdown
*/
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
} }
// ============================================================================ // ============================================================================
@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx(
filename: string, filename: string,
options: DocxOptions = {}, options: DocxOptions = {},
): Promise<void> { ): Promise<void> {
const { codeFont = "Courier New", codeFontSize = 22 } = options; const {
codeFont = "Courier New",
codeFontSize = 22,
resolveAssetUrl,
} = options;
const tokens = marked.lexer(markdown); const tokens = marked.lexer(markdown);
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize }); const children = await parseTokensToDocx(tokens, {
codeFont,
codeFontSize,
resolveAssetUrl,
});
const doc = new DocxDocument({ const doc = new DocxDocument({
sections: [{ children }], sections: [{ children }],
@ -112,7 +126,9 @@ export async function downloadMarkdownAsDocx(
export async function downloadMarkdownAsPdf( export async function downloadMarkdownAsPdf(
markdown: string, markdown: string,
filename: string, filename: string,
options: PdfOptions = {}, options: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
} = {},
): Promise<void> { ): Promise<void> {
const html2pdf = await loadHtml2Pdf(); const html2pdf = await loadHtml2Pdf();
@ -121,10 +137,16 @@ export async function downloadMarkdownAsPdf(
format = "a4", format = "a4",
orientation = "portrait", orientation = "portrait",
scale = 2, scale = 2,
resolveAssetUrl,
} = options; } = options;
const normalizedMarkdown = await rewriteMarkdownImageSources(
markdown,
resolveAssetUrl,
);
// 解析 Markdown 为 HTML // 解析 Markdown 为 HTML
const htmlContent = await marked.parse(markdown); const htmlContent = await marked.parse(normalizedMarkdown);
// 创建容器并应用样式 // 创建容器并应用样式
const container = createStyledContainer(htmlContent); const container = createStyledContainer(htmlContent);
@ -309,16 +331,17 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
/** /**
* Markdown Token DOCX Paragraph * Markdown Token DOCX Paragraph
*/ */
function parseTokensToDocx( async function parseTokensToDocx(
tokens: MarkdownToken[], tokens: MarkdownToken[],
options: Required<DocxOptions>, options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
): Paragraph[] { Pick<DocxOptions, "resolveAssetUrl">,
): Promise<Paragraph[]> {
const paragraphs: Paragraph[] = []; const paragraphs: Paragraph[] = [];
for (const token of tokens) { for (const token of tokens) {
switch (token.type) { switch (token.type) {
case "heading": { case "heading": {
const runs = parseInlineTokens(token.tokens ?? [], options); const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push( paragraphs.push(
new Paragraph({ new Paragraph({
children: runs, children: runs,
@ -330,7 +353,7 @@ function parseTokensToDocx(
} }
case "paragraph": { case "paragraph": {
const runs = parseInlineTokens(token.tokens ?? [], options); const runs = await parseInlineTokens(token.tokens ?? [], options);
paragraphs.push( paragraphs.push(
new Paragraph({ new Paragraph({
children: runs.length > 0 ? runs : [new TextRun("")], children: runs.length > 0 ? runs : [new TextRun("")],
@ -361,8 +384,8 @@ function parseTokensToDocx(
} }
case "list": { case "list": {
token.items?.forEach((item: MarkdownToken) => { for (const item of token.items ?? []) {
const runs = parseInlineTokens( const runs = await parseInlineTokens(
item.tokens?.[0]?.tokens ?? [], item.tokens?.[0]?.tokens ?? [],
options, options,
); );
@ -373,12 +396,12 @@ function parseTokensToDocx(
spacing: { after: 80 }, spacing: { after: 80 },
}), }),
); );
}); }
break; break;
} }
case "blockquote": { case "blockquote": {
const runs = parseInlineTokens( const runs = await parseInlineTokens(
token.tokens?.[0]?.tokens ?? [], token.tokens?.[0]?.tokens ?? [],
options, options,
); );
@ -407,6 +430,19 @@ function parseTokensToDocx(
paragraphs.push(new Paragraph({ children: [] })); paragraphs.push(new Paragraph({ children: [] }));
break; break;
} }
case "image": {
const imageRun = await createImageRunFromToken(token, options);
if (imageRun) {
paragraphs.push(
new Paragraph({
children: [imageRun],
spacing: { after: 200 },
}),
);
}
break;
}
} }
} }
@ -416,11 +452,12 @@ function parseTokensToDocx(
/** /**
* Token TextRun * Token TextRun
*/ */
function parseInlineTokens( async function parseInlineTokens(
tokens: MarkdownToken[], tokens: MarkdownToken[],
options: Required<DocxOptions>, options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
): TextRun[] { Pick<DocxOptions, "resolveAssetUrl">,
const runs: TextRun[] = []; ): Promise<ParagraphChild[]> {
const runs: ParagraphChild[] = [];
for (const token of tokens) { for (const token of tokens) {
switch (token.type) { switch (token.type) {
@ -460,6 +497,14 @@ function parseInlineTokens(
runs.push(new TextRun({ text: "", break: 1 })); runs.push(new TextRun({ text: "", break: 1 }));
break; break;
case "image": {
const imageRun = await createImageRunFromToken(token, options);
if (imageRun) {
runs.push(imageRun);
}
break;
}
default: default:
runs.push(new TextRun(token.raw ?? "")); runs.push(new TextRun(token.raw ?? ""));
} }
@ -468,6 +513,155 @@ function parseInlineTokens(
return runs; return runs;
} }
async function createImageRunFromToken(
token: MarkdownToken,
options: Pick<DocxOptions, "resolveAssetUrl">,
): Promise<ImageRun | null> {
const rawHref = String(token?.href ?? token?.text ?? "").trim();
if (!rawHref) return null;
const resolvedUrl = await resolveAssetReference(rawHref, options.resolveAssetUrl);
if (!resolvedUrl || !isRenderableImageUrl(resolvedUrl)) {
return null;
}
try {
const response = await fetch(resolvedUrl);
if (!response.ok) {
return null;
}
const blob = await response.blob();
const imageType = getDocxImageType(blob.type, resolvedUrl);
if (!imageType) {
return null;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
const { width, height } = await getImageDimensions(blob);
const maxWidth = 560;
const scale = width > maxWidth ? maxWidth / width : 1;
return new ImageRun({
data: bytes,
type: imageType,
transformation: {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
},
});
} catch {
return null;
}
}
async function getImageDimensions(
blob: Blob,
): Promise<{ width: number; height: number }> {
return await new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const width = img.naturalWidth || 1;
const height = img.naturalHeight || 1;
URL.revokeObjectURL(url);
resolve({ width, height });
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve({ width: 600, height: 400 });
};
img.src = url;
});
}
async function rewriteMarkdownImageSources(
markdown: string,
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>,
): Promise<string> {
if (!resolveAssetUrl) {
return markdown;
}
let rewritten = markdown;
const markdownMatches = [...rewritten.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)];
for (const match of markdownMatches) {
const alt = match[1] ?? "";
const rawTarget = match[2]?.trim() ?? "";
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
if (!resolved || resolved === rawTarget) continue;
rewritten = rewritten.replace(
match[0],
`![${alt}](${resolved})`,
);
}
const htmlMatches = [...rewritten.matchAll(/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi)];
for (const match of htmlMatches) {
const rawTarget = match[3]?.trim() ?? "";
const resolved = await resolveAssetReference(rawTarget, resolveAssetUrl);
if (!resolved || resolved === rawTarget) continue;
rewritten = rewritten.replace(
match[0],
`${match[1]}${match[2]}${resolved}${match[2]}`,
);
}
return rewritten;
}
async function resolveAssetReference(
rawPath: string,
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>,
): Promise<string | null> {
const normalized = normalizeReference(rawPath);
if (!normalized) return null;
if (isExternalReference(normalized)) return normalized;
if (!resolveAssetUrl) return normalized;
return (await resolveAssetUrl(normalized)) ?? normalized;
}
function normalizeReference(ref: string): string {
const trimmed = ref.trim().replace(/^<|>$/g, "");
return trimmed.split(/[ \t]/)[0] ?? "";
}
function isExternalReference(ref: string): boolean {
return (
!ref ||
ref.startsWith("#") ||
ref.startsWith("//") ||
ref.startsWith("data:") ||
ref.startsWith("blob:") ||
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref)
);
}
function isRenderableImageUrl(url: string): boolean {
if (url.startsWith("data:image/")) return true;
if (/\.(png|jpe?g|gif|webp|bmp|ico|avif|tiff?)([?#].*)?$/i.test(url))
return true;
if (/^https?:\/\//i.test(url)) return true;
if (url.startsWith("/")) return true;
return false;
}
function getDocxImageType(
mimeType: string,
src: string,
): "png" | "jpg" | "gif" | "bmp" {
const mime = mimeType.toLowerCase();
if (mime.includes("png")) return "png";
if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg";
if (mime.includes("gif")) return "gif";
if (mime.includes("bmp")) return "bmp";
const lower = src.toLowerCase();
if (lower.includes(".png")) return "png";
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "jpg";
if (lower.includes(".gif")) return "gif";
if (lower.includes(".bmp")) return "bmp";
return "png";
}
/** /**
* *
*/ */

View File

@ -1,6 +1,11 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter"; import {
downloadMarkdownAsDocx,
downloadMarkdownAsPdf,
type DocxOptions,
type PdfOptions,
} from "./converter";
/** /**
* Markdown Hook * Markdown Hook
@ -31,11 +36,21 @@ export interface UseMarkdownDownloadReturn {
/** /**
* DOCX * DOCX
*/ */
downloadAsDocx: (markdown: string, filename: string) => Promise<void>; downloadAsDocx: (
markdown: string,
filename: string,
options?: DocxOptions,
) => Promise<void>;
/** /**
* PDF * PDF
*/ */
downloadAsPdf: (markdown: string, filename: string) => Promise<void>; downloadAsPdf: (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
},
) => Promise<void>;
/** /**
* *
*/ */
@ -82,14 +97,14 @@ export function useMarkdownDownload(
); );
const downloadAsDocx = useCallback( const downloadAsDocx = useCallback(
async (markdown: string, filename: string) => { async (markdown: string, filename: string, options?: DocxOptions) => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading("docx"); setIsDownloading("docx");
onDownloadStart?.("docx"); onDownloadStart?.("docx");
try { try {
await downloadMarkdownAsDocx(markdown, filename); await downloadMarkdownAsDocx(markdown, filename, options);
} catch (error) { } catch (error) {
onError?.( onError?.(
error instanceof Error ? error : new Error(String(error)), error instanceof Error ? error : new Error(String(error)),
@ -104,14 +119,20 @@ export function useMarkdownDownload(
); );
const downloadAsPdf = useCallback( const downloadAsPdf = useCallback(
async (markdown: string, filename: string) => { async (
markdown: string,
filename: string,
options?: PdfOptions & {
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
},
) => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading("pdf"); setIsDownloading("pdf");
onDownloadStart?.("pdf"); onDownloadStart?.("pdf");
try { try {
await downloadMarkdownAsPdf(markdown, filename); await downloadMarkdownAsPdf(markdown, filename, options);
} catch (error) { } catch (error) {
onError?.( onError?.(
error instanceof Error ? error : new Error(String(error)), error instanceof Error ? error : new Error(String(error)),

View File

@ -1,13 +1,16 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "sonner";
import { import {
POST_MESSAGE_TYPES, POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES, RECEIVE_MESSAGE_TYPES,
isSelectedSkillMessage, isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
sendToParent, sendToParent,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型 // Skill 数据类型
interface SkillData { interface SkillData {
@ -15,22 +18,113 @@ interface SkillData {
title: string; title: string;
} }
const STORAGE_KEYS = {
latest: "iframe:selectedSkills:latest",
byThreadPrefix: "iframe:selectedSkills:thread:",
} as const;
function getThreadStorageKey(threadId?: string | null): string | null {
const normalized = threadId?.trim();
if (!normalized) return null;
return `${STORAGE_KEYS.byThreadPrefix}${normalized}`;
}
function parseStoredSkills(raw: string | null): SkillData[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map((item) => {
if (typeof item !== "object" || item === null) return null;
const record = item as Record<string, unknown>;
const skillId = String(record.skill_id ?? "").trim();
const title = String(record.title ?? "").trim();
if (!skillId || !title) return null;
return { skill_id: skillId, title };
})
.filter((item): item is SkillData => item !== null);
} catch {
return [];
}
}
function removeSkillsByIdsFromList(skills: SkillData[], skillIds: string[]): SkillData[] {
if (skillIds.length === 0) return skills;
const idSet = new Set(skillIds.map((id) => String(id)));
return skills.filter((skill) => !idSet.has(String(skill.skill_id)));
}
// Hook 返回类型 // Hook 返回类型
interface UseIframeSkillReturn { interface UseIframeSkillReturn {
selectedSkill: SkillData | null; selectedSkill: SkillData | null;
selectedSkills: SkillData[];
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
openSkillDialog: () => void; openSkillDialog: () => void;
clearSkill: () => void; clearSkill: (skillId?: string) => void;
} }
export function useIframeSkill(): UseIframeSkillReturn { interface UseIframeSkillOptions {
threadId?: string | null;
}
export function useIframeSkill(
options?: UseIframeSkillOptions,
): UseIframeSkillReturn {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id"); const threadIdFromQuery = searchParams.get("thread_id");
const threadId = options?.threadId?.trim() || threadIdFromQuery;
const isChattingFromQuery = searchParams.get("is_chatting"); const isChattingFromQuery = searchParams.get("is_chatting");
const lastThreadIdRef = useRef<string | null>(null); const lastThreadIdRef = useRef<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null); const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [selectedSkills, setSelectedSkills] = useState<SkillData[]>([]);
const [isBootstrapping, setIsBootstrapping] = useState(false);
const removeFailedSkills = useCallback(
(skillIds: string[]) => {
if (skillIds.length === 0) return;
// 1) 回滚内存状态:移除失败 skill避免展示错误 tag
setSelectedSkills((prev) => {
const next = removeSkillsByIdsFromList(prev, skillIds);
setSelectedSkill(next[0] ?? null);
return next;
});
// 2) 回滚 localStoragelatest + thread
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeSkillsByIdsFromList(latestSkills, skillIds);
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
const threadSkills = parseStoredSkills(window.localStorage.getItem(threadKey));
const nextThreadSkills = removeSkillsByIdsFromList(threadSkills, skillIds);
if (nextThreadSkills.length > 0) {
window.localStorage.setItem(threadKey, JSON.stringify(nextThreadSkills));
} else {
window.localStorage.removeItem(threadKey);
}
}
},
[threadId],
);
// 1. 监听 query 参数变化(临时禁用) // 1. 监听 query 参数变化(临时禁用)
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。 // TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
@ -43,31 +137,80 @@ export function useIframeSkill(): UseIframeSkillReturn {
// }, [searchParams]); // }, [searchParams]);
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面 // 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
useEffect(() => { // useEffect(() => {
if (!threadIdFromQuery) return; // if (!threadId) return;
if (isChattingFromQuery !== "true") return; // if (isChattingFromQuery !== "true") return;
if (lastThreadIdRef.current === threadIdFromQuery) return; // if (lastThreadIdRef.current === threadId) return;
lastThreadIdRef.current = threadIdFromQuery; // lastThreadIdRef.current = threadId;
router.replace(`/workspace/chats/${threadIdFromQuery}`); // router.replace(`/workspace/chats/${threadId}`);
}, [isChattingFromQuery, router, threadIdFromQuery]); // }, [isChattingFromQuery, router, threadId]);
// 2. 监听宿主页 postMessage // 2. 监听宿主页 postMessage
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { if (isSelectedSkillMessage(event.data)) {
return;
}
if (!isSelectedSkillMessage(event.data)) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
return;
}
const { id, title } = event.data; const { id, title } = event.data;
setSelectedSkill({ skill_id: String(id), title }); const singleSkill = { skill_id: String(id), title };
setSelectedSkill(singleSkill);
setSelectedSkills([singleSkill]);
return;
}
if (isSelectedSkillsMessage(event.data)) {
const normalizedSkills = event.data.selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
if (normalizedSkills.length === 0) {
setSelectedSkill(null);
setSelectedSkills([]);
return;
}
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
return;
}
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
}
}; };
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);
}, []); }, []);
// 3. 首次进入时恢复 localStorage 中上次选择的 skill线程优先其次全局
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
const threadSkills = threadKey
? parseStoredSkills(window.localStorage.getItem(threadKey))
: [];
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const restoredSkills = threadSkills.length > 0 ? threadSkills : latestSkills;
if (restoredSkills.length === 0) return;
setSelectedSkills(restoredSkills);
setSelectedSkill(restoredSkills[0] ?? null);
}, [threadId]);
// 4. 选择变化时同步到 localStorage
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
if (selectedSkills.length === 0) {
// 空数组也要同步到存储,避免 UI 状态与缓存不一致
window.localStorage.removeItem(STORAGE_KEYS.latest);
if (threadKey) {
window.localStorage.removeItem(threadKey);
}
return;
}
const payload = JSON.stringify(selectedSkills);
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
if (threadKey) {
window.localStorage.setItem(threadKey, payload);
}
}, [selectedSkills, threadId]);
// 发送选择预定义 skill // 发送选择预定义 skill
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => { const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills }; const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
@ -75,6 +218,94 @@ export function useIframeSkill(): UseIframeSkillReturn {
sendToParent(message); sendToParent(message);
}, []); }, []);
const bootstrapAndLockSkills = useCallback(
async ({
selectedSkills,
title,
}: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => {
if (!threadId) {
toast.error("技能加载失败", {
description: "缺少 thread_id无法初始化技能",
});
return false;
}
const content_ids = Array.from(
new Set(
selectedSkills
.map((item) => Number(String(item.id).trim()))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (content_ids.length === 0) {
toast.error("技能加载失败", {
description: "无效的 skill_id",
});
return false;
}
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "suggest-skill-bootstrap" });
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
toast.dismiss("suggest-skill-bootstrap");
if (!result.success) {
const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds);
toast.error(`技能「${title}」加载失败`, {
description: result.message || "未知错误",
});
return false;
}
sendSelectSkill(selectedSkills);
const normalizedSkills = selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
toast.success(`技能「${title}」加载成功`, {
description: result.message || `已创建 ${result.created_files} 个文件`,
});
return true;
} catch (error) {
const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap");
const message =
error instanceof Error ? error.message : "网络请求失败";
toast.error(`技能「${title}」加载失败`, {
description: message,
});
return false;
} finally {
setIsBootstrapping(false);
}
},
[removeFailedSkills, searchParams, sendSelectSkill, threadId],
);
// 打开 skill 选择对话框 // 打开 skill 选择对话框
const openSkillDialog = useCallback(() => { const openSkillDialog = useCallback(() => {
const message = { const message = {
@ -86,13 +317,66 @@ export function useIframeSkill(): UseIframeSkillReturn {
}, []); }, []);
// 清除选中并发送空 selectedSkills 数组给主页 // 清除选中并发送空 selectedSkills 数组给主页
const clearSkill = useCallback(() => { const clearSkill = useCallback(
setSelectedSkill(null); (skillId?: string) => {
// 发送空数组给主页,通知取消选择 const removeAll = !skillId;
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] }; const nextSelectedSkills = removeAll
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message); ? []
sendToParent(message); : selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
}, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; setSelectedSkills(nextSelectedSkills);
setSelectedSkill(nextSelectedSkills[0] ?? null);
// 同步 latest 缓存:仅删除对应 skill或全部清空
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeAll
? []
: latestSkills.filter((skill) => skill.skill_id !== String(skillId));
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
// 同步线程缓存:保存剩余数组,空则删除 key
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
if (nextSelectedSkills.length > 0) {
window.localStorage.setItem(
threadKey,
JSON.stringify(nextSelectedSkills),
);
} else {
window.localStorage.removeItem(threadKey);
}
}
// 通知宿主页当前剩余技能
const message = {
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: nextSelectedSkills.map((skill) => ({
id: skill.skill_id,
name: skill.title,
})),
} as const;
console.log("[useIframeSkill] clearSkill:", message);
sendToParent(message);
},
[selectedSkills, threadId],
);
return {
selectedSkill,
selectedSkills,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,
openSkillDialog,
clearSkill,
};
} }

View File

@ -2,7 +2,11 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react"; import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { isSelectedSkillMessage } from "@/core/iframe-messages"; import {
isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem,
} from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api"; import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 技能基础数据 */ /** 技能基础数据 */
@ -51,14 +55,20 @@ export function useSelectedSkillListener({
const skillBootstrappedKeyRef = useRef<string | null>(null); const skillBootstrappedKeyRef = useRef<string | null>(null);
const performBootstrap = useCallback( const performBootstrap = useCallback(
async (id: number | string, title: string) => { async (skills: SelectedSkillPayloadItem[], title: string) => {
if (!threadId) return; if (!threadId) return;
const contentId = Number(id); const contentIds = Array.from(
if (!Number.isFinite(contentId) || contentId <= 0) { new Set(
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id); skills
.map((skill) => Number(String(skill.id).trim()))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (contentIds.length === 0) {
console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills);
setSkillError({ setSkillError({
title: `技能「${title}」加载失败`, title: `技能「${title}」加载失败`,
message: `非法 skill id: ${String(id)}`, message: "非法 skill_id 数组",
}); });
return; return;
} }
@ -68,13 +78,13 @@ export function useSelectedSkillListener({
searchParams.get("language_type")?.trim(); searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const initKey = `${threadId}:${id}:${languageType}`; const initKey = `${threadId}:${contentIds.join(",")}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) { if (skillBootstrappedKeyRef.current === initKey) {
return; return;
} }
console.log( console.log(
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`, `[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
); );
setIsBootstrapping(true); setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
@ -82,7 +92,7 @@ export function useSelectedSkillListener({
try { try {
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
thread_id: threadId, thread_id: threadId,
content_ids: [contentId], content_ids: contentIds,
language_type: languageType, language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill", target_dir: "/mnt/user-data/uploads/skill",
clear_target: true, clear_target: true,
@ -123,23 +133,39 @@ export function useSelectedSkillListener({
if (skillIdFromQuery && titleFromQuery) { if (skillIdFromQuery && titleFromQuery) {
isFirstLoadRef.current = true; isFirstLoadRef.current = true;
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
void performBootstrap(skillIdFromQuery, titleFromQuery); void performBootstrap(
[{ id: skillIdFromQuery, name: titleFromQuery }],
titleFromQuery,
);
} }
}, [threadId, searchParams, performBootstrap]); }, [threadId, searchParams, performBootstrap]);
const handleMessage = useCallback( const handleMessage = useCallback(
(event: MessageEvent) => { (event: MessageEvent) => {
if (!isSelectedSkillMessage(event.data)) return; if (isSelectedSkillMessage(event.data)) {
const data = event.data; const data = event.data;
const { id, title } = data; const { id, title } = data;
console.log( console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:", "[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data, data,
); );
setSelectedSkill({ skill_id: String(id), title }); setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title); void performBootstrap([{ id, name: title }], title);
return;
}
if (isSelectedSkillsMessage(event.data)) {
const { selectedSkills } = event.data;
if (!selectedSkills.length) return;
const first = selectedSkills[0]!;
const firstTitle = first.name;
console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkills:",
event.data,
);
setSelectedSkill({ skill_id: String(first.id), title: firstTitle });
void performBootstrap(selectedSkills, firstTitle);
}
}, },
[performBootstrap], [performBootstrap],
); );

View File

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@ -18,14 +19,14 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
export async function copyToClipboard(text: string): Promise<void> { export async function copyToClipboard(text: string): Promise<void> {
const isInIframe = window.self !== window.top; const isInIframe = window.self !== window.top;
const message = { const message = {
type: "copyToClipboard", type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
text, text,
}; } as const;
if (isInIframe && window.parent) { if (isInIframe) {
try { try {
// Request parent window to copy // Request parent window to copy
window.parent.postMessage(message, "*"); sendToParent(message);
console.log( console.log(
"[copyToClipboard] iframe mode → postMessage to parent", "[copyToClipboard] iframe mode → postMessage to parent",
message, message,

View File

@ -411,6 +411,12 @@
--container-width-lg: calc(var(--spacing) * 256); --container-width-lg: calc(var(--spacing) * 256);
} }
html,
body {
font-family: "Microsoft YaHei", "微软雅黑", "PingFang SC", ui-sans-serif,
system-ui, sans-serif;
}
/* ======================================== /* ========================================
Streamdown Markdown Styles Streamdown Markdown Styles
使用 data-streamdown 属性选择器统一定义 使用 data-streamdown 属性选择器统一定义
@ -452,9 +458,9 @@ pre {
font-size: calc(16px * var(--zoom-scale)); font-size: calc(16px * var(--zoom-scale));
} }
/* 二三级标题 - 16px */ /* 代码块 - 14px */
[data-streamdown="code-block"] pre { [data-streamdown="code-block"] pre {
font-size: calc(16px * var(--zoom-scale)); font-size: calc(14px * var(--zoom-scale));
} }
.cm-line { .cm-line {
@ -485,3 +491,11 @@ pre {
overflow: hidden; overflow: hidden;
contain: paint; contain: paint;
} }
.pptx-preview-wrap {
height: 100%;
}
.pptx-preview-wrap .pptx-preview-wrapper {
height: 100% !important;
}

View File

@ -0,0 +1,222 @@
import {
expect,
test,
type Locator,
type Page,
type TestInfo,
} from "@playwright/test";
import { v4 as uuid } from "uuid";
import {
newChatEntry,
openChat,
sendMessage,
waitForMessageListReady,
} from "./support/chat-helpers";
const FILE_CASES = [
{ kind: "html", label: "html", regex: /\.html?/i },
{
kind: "image",
label: "image",
regex: /\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)/i,
},
{ kind: "md", label: "md", regex: /\.md/i },
{ kind: "docx", label: "docx", regex: /\.docx?/i },
{ kind: "pptx", label: "pptx", regex: /\.pptx?/i },
{ kind: "xlsx", label: "xlsx", regex: /\.xlsx?/i },
] as const;
test.use({
video: "on",
screenshot: "on",
});
test.describe("聊天工作台 / 智能体产物生成预览与下载", () => {
test("DF-ART-GEN-001 生成并逐个点击 html/image/md/docx/pptx/xlsx 卡片截图", async ({
page,
}, testInfo) => {
const startedAt = Date.now();
test.setTimeout(12 * 60 * 1000);
const threadId = uuid();
logStatus("开始测试", `threadId=${threadId}`);
await openChat(page, newChatEntry(threadId));
await waitForMessageListReady(page, { requireMessages: false });
logStatus("发送生成指令");
await sendMessage(page, buildGeneratePrompt());
await waitForArtifactsReady(page, FILE_CASES, startedAt);
await openArtifactPanel(page);
logStatus("Artifacts 列表已就绪,开始逐类校验");
await capture(page, testInfo, "artifact-list-ready");
for (const file of FILE_CASES) {
logStatus("校验文件类型", file.label);
const card = artifactCardByPattern(page, file.regex);
await expect(card).toBeVisible();
await card.click();
logStatus("点击并截图", file.label);
await waitAfterCardClick(page, file.kind);
await capture(page, testInfo, `card-clicked-${file.label}`);
logStatus("类型处理完成", file.label);
}
logStatus("测试完成");
});
});
function buildGeneratePrompt(): string {
return [
"请一次性创建以下 6 个文件到 /mnt/user-data/outputs并在完成后调用 present_files",
"1) e2e-artifacts-page.html包含标题 DF_E2E_HTML 和一段正文。",
"2) e2e-artifacts-image.png生成一张包含文字 DF_E2E_IMAGE 的图片。",
"3) e2e-artifacts-notes.md标题为 DF_E2E_MD并引用上面的图片。",
"4) e2e-artifacts-report.docx包含标题 DF_E2E_DOCX 和一段文字。",
"5) e2e-artifacts-slides.pptx至少 2 页,包含 DF_E2E_PPTX。",
"6) e2e-artifacts-table.xlsx至少 2 列 3 行,并包含 DF_E2E_XLSX。",
"注意:所有文件都要真实写入输出目录,不要只在回复里描述。",
].join("\n");
}
async function openArtifactPanel(page: Page): Promise<void> {
const button = page.getByTestId("artifacts-open-button");
await expect(button).toBeVisible({ timeout: 120_000 });
await button.click();
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible();
}
async function waitForArtifactsReady(
page: Page,
requiredCases: ReadonlyArray<(typeof FILE_CASES)[number]>,
startedAt: number,
): Promise<void> {
let pollRound = 0;
await expect
.poll(
async () => {
pollRound += 1;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
const list = page.getByTestId("artifact-file-list").first();
// 1) 优先直接检查已展示的 artifact-file-list
if (!(await list.isVisible().catch(() => false))) {
// 2) 列表不存在时再尝试通过按钮打开
const openButton = page.getByTestId("artifacts-open-button").first();
if (!(await openButton.isVisible().catch(() => false))) {
logStatus(
"等待 artifacts 入口或列表出现",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s`,
);
return false;
}
await openButton.click();
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible({
timeout: 5_000,
});
}
const allFileNames = await getArtifactFileNames(page);
const found = requiredCases
.filter((fileCase) =>
allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
const missing = requiredCases
.filter(
(fileCase) =>
!allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
logStatus(
"等待文件类型齐全",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s, 已找到=[${found.join(", ")}], 缺失=[${missing.join(", ")}]`,
);
return missing.length === 0;
},
{
timeout: 8 * 60 * 1000,
intervals: [1000, 2000, 3000, 5000],
},
)
.toBeTruthy();
}
function artifactCardByPattern(page: Page, pattern: RegExp): Locator {
return page.locator("[data-testid='artifact-file-card']").filter({
has: page.locator("[data-slot='card-title'] div[title]").filter({
hasText: pattern,
}),
}).first();
}
async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
if (kind === "docx") {
await expect(page.locator(".docx-preview-wrap").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "xlsx") {
await expect(page.locator("#artifact-xlsx-preview").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "pptx") {
await expect(page.locator(".pptx-preview-wrap").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "md") {
await page.waitForTimeout(1200);
return;
}
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible({
timeout: 30_000,
});
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, {
path,
contentType: "image/png",
});
}
function logStatus(step: string, detail?: string): void {
const timestamp = new Date().toISOString();
if (detail) {
console.log(`[E2E][${timestamp}] ${step} | ${detail}`);
return;
}
console.log(`[E2E][${timestamp}] ${step}`);
}
async function getArtifactFileNames(page: Page): Promise<string[]> {
const titleNodes = page.locator(
"[data-testid='artifact-file-card'] [data-slot='card-title'] div[title]",
);
const titleCount = await titleNodes.count();
if (titleCount > 0) {
const names: string[] = [];
for (let i = 0; i < titleCount; i += 1) {
const value = (await titleNodes.nth(i).getAttribute("title"))?.trim();
if (value) {
names.push(value);
}
}
return names;
}
// fallback: if title attr is absent, use first text line of each card
const cardTexts = await page.getByTestId("artifact-file-card").allTextContents();
return cardTexts
.map((text) => text.split("\n")[0]?.trim() ?? "")
.filter(Boolean);
}

View File

@ -1,11 +1,13 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { import {
PRIMARY_THREAD_ID,
THREAD_WITH_ARTIFACTS, THREAD_WITH_ARTIFACTS,
THREAD_WITH_HTML_ARTIFACT, THREAD_WITH_HTML_ARTIFACT,
THREAD_WITH_IMAGE_ARTIFACT, THREAD_WITH_IMAGE_ARTIFACT,
openChat, openChat,
reuseThreadChatEntry, reuseThreadChatEntry,
sendMessage,
skipIfMissingThread, skipIfMissingThread,
waitForAnyMessages, waitForAnyMessages,
waitForMessageListReady, waitForMessageListReady,
@ -87,8 +89,22 @@ test.describe("聊天工作台 / Artifact 面板", () => {
(await htmlFile.count()) === 0, (await htmlFile.count()) === 0,
"当前线程没有 HTML artifact。", "当前线程没有 HTML artifact。",
); );
const htmlArtifactResponsePromise = page.waitForResponse((response) => {
const url = decodeURIComponent(response.url());
return (
response.status() === 200 &&
/\/api\/threads\/[^/]+\/artifacts\//.test(url) &&
/\.html?($|\?)/i.test(url)
);
});
await htmlFile.click(); await htmlFile.click();
const htmlArtifactResponse = await htmlArtifactResponsePromise;
expect(htmlArtifactResponse.headers()["content-disposition"] ?? "").toContain(
"attachment;",
);
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible(); await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
}); });
@ -117,4 +133,32 @@ test.describe("聊天工作台 / Artifact 面板", () => {
await expect(page.getByTestId("artifacts-open-button")).toBeVisible(); await expect(page.getByTestId("artifacts-open-button")).toBeVisible();
await expect(page.getByRole("log").first()).toBeVisible(); await expect(page.getByRole("log").first()).toBeVisible();
}); });
test("DF-ART-005 生成简单 HTML 后出现 artifact-file-card", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await sendMessage(page, "生成一个简单的html文件");
await expect
.poll(
async () => await page.getByTestId("artifacts-open-button").count(),
{ timeout: 120_000 },
)
.toBeGreaterThan(0);
await page.getByTestId("artifacts-open-button").click();
await expect
.poll(
async () => await page.getByTestId("artifact-file-card").count(),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
});
}); });

View File

@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test";
import {
PRIMARY_THREAD_ID,
openChat,
reuseThreadChatEntry,
skipIfMissingThread,
waitForMessageListReady,
} from "./support/chat-helpers";
test.use({
video: "on",
});
test.describe("聊天工作台 / 错误提示", () => {
test("DF-ERR-001 对话流失败时显示错误 toast", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await page.route("**/*", async (route) => {
const request = route.request();
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
await route.abort("failed");
return;
}
await route.continue();
});
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
await textarea.fill("触发错误 toast 测试");
await submit.click({ force: true });
await expect(
page
.locator("[data-sonner-toast]")
.filter({ hasText: "出现了某些错误。" })
.first(),
).toBeVisible({ timeout: 10_000 });
});
test("DF-ERR-002 相同错误短时间不重复弹 toast", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
await waitForMessageListReady(page, { requireMessages: false });
await page.route("**/*", async (route) => {
const request = route.request();
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
await route.abort("failed");
return;
}
await route.continue();
});
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
const errorToasts = page.locator('[data-sonner-toast][data-type="error"]');
await textarea.fill("触发重复错误 toast 测试 1");
await submit.click({ force: true });
await expect(errorToasts.first()).toBeVisible({ timeout: 10_000 });
await expect(errorToasts).toHaveCount(1);
// 在去重窗口2s内再次触发同类错误不应新增 toast
await textarea.fill("触发重复错误 toast 测试 2");
await submit.click({ force: true });
await expect(errorToasts).toHaveCount(1);
});
});

View File

@ -1,15 +1,22 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { v4 as uuid } from "uuid";
import { import {
THREAD_FOR_WELCOME, THREAD_FOR_WELCOME,
newChatEntry, newChatEntry,
openChat, openChat,
reuseThreadChatEntry, reuseThreadChatEntry,
sendMessage,
skipIfMissingThread, skipIfMissingThread,
waitForAnyMessages, waitForAnyMessages,
waitForMessageListReady, waitForMessageListReady,
} from "./support/chat-helpers"; } from "./support/chat-helpers";
test.use({
screenshot: "on",
video: "on",
});
test.describe("线程路由(无 isnew", () => { test.describe("线程路由(无 isnew", () => {
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => { test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
@ -29,4 +36,67 @@ test.describe("线程路由(无 isnew", () => {
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`)); await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`));
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
}); });
test("/new 使用 uuid thread_id 发送后触发 stream(cod=0) 并进入 thread 路由", async ({
page,
}) => {
const threadId = uuid();
const text = `e2e-${threadId.slice(0, 8)}`;
await openChat(page, newChatEntry(threadId));
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
const streamRequestPromise = page.waitForRequest(
(request) => {
const url = request.url();
if (!url.includes("/stream")) return false;
if (!url.includes(threadId)) return false;
try {
const parsed = new URL(url);
return parsed.searchParams.get("cancel_on_disconnect") === "0";
} catch {
return url.includes("cancel_on_disconnect=0");
}
},
{ timeout: 30_000 },
);
await sendMessage(page, text);
await expect(page.locator(".is-user").filter({ hasText: text })).toHaveCount(1);
await expect
.poll(
async () => await page.locator(".is-assistant").count(),
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
const streamRequest = await streamRequestPromise;
expect(streamRequest.url()).toContain("cancel_on_disconnect=0");
await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${threadId}\\?is_chatting=true`),
{ timeout: 30_000 },
);
});
test("streaming 中点击停止可中断输出", async ({ page }) => {
const threadId = uuid();
const text =
"请逐行输出 1 到 500 的数字并在每一行前面加上“第N行”前缀不要省略。";
await openChat(page, newChatEntry(threadId));
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
await sendMessage(page, text);
const submitButton = page.locator("button[aria-label='Submit']");
await expect(submitButton).toHaveText("停止", { timeout: 30_000 });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// 点击停止后应退出 streaming 态,按钮文本不再是“停止”
await expect(submitButton).toHaveText("发送", { timeout: 30_000 });
});
}); });