Compare commits
No commits in common. "v3.2.3" and "main" have entirely different histories.
|
|
@ -269,8 +269,7 @@ 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_files` tool
|
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` 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>
|
||||||
|
|
||||||
|
|
@ -348,8 +347,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 `\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.
|
||||||
|
|
@ -496,7 +495,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_files`"
|
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,11 @@
|
||||||
"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",
|
||||||
|
|
@ -81,7 +79,6 @@
|
||||||
"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",
|
||||||
|
|
@ -97,7 +94,6 @@
|
||||||
"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": {
|
||||||
|
|
|
||||||
|
|
@ -137,9 +137,6 @@ 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
|
||||||
|
|
@ -155,9 +152,6 @@ 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
|
||||||
|
|
@ -191,9 +185,6 @@ 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
|
||||||
|
|
@ -239,9 +230,6 @@ 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
|
||||||
|
|
@ -2560,10 +2548,6 @@ 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'}
|
||||||
|
|
@ -2819,10 +2803,6 @@ 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'}
|
||||||
|
|
@ -2895,10 +2875,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -2958,11 +2934,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -3235,9 +3206,6 @@ 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'}
|
||||||
|
|
@ -3253,9 +3221,6 @@ 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:
|
||||||
|
|
@ -3588,10 +3553,6 @@ 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:
|
||||||
|
|
@ -4229,9 +4190,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -4860,9 +4818,6 @@ 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'}
|
||||||
|
|
@ -5319,10 +5274,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -5498,9 +5449,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -5852,23 +5800,10 @@ 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
|
||||||
|
|
@ -5901,9 +5836,6 @@ 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'}
|
||||||
|
|
@ -8302,8 +8234,6 @@ 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)
|
||||||
|
|
@ -8546,11 +8476,6 @@ 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
|
||||||
|
|
@ -8639,8 +8564,6 @@ 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:
|
||||||
|
|
@ -8686,8 +8609,6 @@ 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:
|
||||||
|
|
@ -8978,10 +8899,6 @@ 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
|
||||||
|
|
@ -9003,11 +8920,6 @@ 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
|
||||||
|
|
@ -9537,8 +9449,6 @@ 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
|
||||||
|
|
@ -10244,8 +10154,6 @@ 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:
|
||||||
|
|
@ -11271,14 +11179,6 @@ 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):
|
||||||
|
|
@ -11871,10 +11771,6 @@ 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:
|
||||||
|
|
@ -12070,8 +11966,6 @@ 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: {}
|
||||||
|
|
@ -12441,22 +12335,8 @@ 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
|
||||||
|
|
@ -12477,10 +12357,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
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, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, 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";
|
||||||
|
|
@ -29,7 +28,6 @@ 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";
|
||||||
|
|
@ -65,21 +63,16 @@ export default function ChatPage() {
|
||||||
isMock,
|
isMock,
|
||||||
showWelcomeStyle,
|
showWelcomeStyle,
|
||||||
} = useThreadChat();
|
} = useThreadChat();
|
||||||
|
|
||||||
// 新逻辑:历史渲染和新会话仅由路由 /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) {
|
||||||
|
|
@ -87,38 +80,9 @@ 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,8 +99,8 @@ export default function ChatPage() {
|
||||||
onStart: (currentThreadId) => {
|
onStart: (currentThreadId) => {
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
// if (!shouldStayOnNewRoute) {
|
// if (!shouldStayOnNewRoute) {
|
||||||
// Keep /new in history so router.back() can return to it.
|
// Keep /new in history so router.back() can return to it.
|
||||||
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
|
router.replace(`/workspace/chats/${currentThreadId}?is_chatting=true`);
|
||||||
// }
|
// }
|
||||||
// history.pushState(null, "", pathOfThread(currentThreadId));
|
// history.pushState(null, "", pathOfThread(currentThreadId));
|
||||||
},
|
},
|
||||||
|
|
@ -171,16 +135,10 @@ export default function ChatPage() {
|
||||||
setHistoryCutoff(null);
|
setHistoryCutoff(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSubmitted) return;
|
if (historyCutoff === null && !thread.isThreadLoading) {
|
||||||
// Welcome 态下、未提交前,把当前已有消息都当作“历史”切掉。
|
setHistoryCutoff(thread.messages.length);
|
||||||
// 这样即使历史消息是后续异步补齐,也不会重新露出。
|
}
|
||||||
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,
|
||||||
|
|
@ -235,30 +193,15 @@ 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);
|
||||||
if (safeThreadId && (isNewThread || showWelcomeStyle)) {
|
void sendMessage(threadId, message);
|
||||||
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();
|
||||||
|
|
@ -279,10 +222,10 @@ export default function ChatPage() {
|
||||||
setArtifactsOpen,
|
setArtifactsOpen,
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -309,7 +252,6 @@ 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
|
||||||
|
|
@ -330,9 +272,8 @@ 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={'来,一起学习工作吧'} />
|
<ThreadTitle threadId={threadId} threadTitle={title} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
<div className="flex items-center justify-end gap-2 overflow-hidden">
|
||||||
|
|
@ -387,11 +328,9 @@ export default function ChatPage() {
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
messagesOverride={
|
messagesOverride={
|
||||||
shouldRenderHistory
|
shouldRenderHistory || historyCutoff === null
|
||||||
? undefined
|
? undefined
|
||||||
: historyCutoff === null
|
: thread.messages.slice(historyCutoff)
|
||||||
? []
|
|
||||||
: thread.messages.slice(historyCutoff)
|
|
||||||
}
|
}
|
||||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||||
showScrollToBottomButton={!showWelcomeStyle}
|
showScrollToBottomButton={!showWelcomeStyle}
|
||||||
|
|
@ -415,7 +354,6 @@ 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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -452,9 +390,9 @@ export default function ChatPage() {
|
||||||
{t.common.artifacts}
|
{t.common.artifacts}
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-h-0 grow overflow-auto">
|
<main className="min-h-0 grow">
|
||||||
<ArtifactFileList
|
<ArtifactFileList
|
||||||
className="max-w-(--container-width-sm) p-4 pt-12 mb-[207px]"
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
files={thread.values.artifacts ?? []}
|
files={thread.values.artifacts ?? []}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
/>
|
/>
|
||||||
|
|
@ -506,8 +444,7 @@ 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}
|
||||||
|
|
@ -538,7 +475,7 @@ export default function ChatPage() {
|
||||||
<DevDialogTitle>提示</DevDialogTitle>
|
<DevDialogTitle>提示</DevDialogTitle>
|
||||||
</DevDialogHeader>
|
</DevDialogHeader>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
(测试中:计划销毁但是现在没有销毁) 退出后,当前会话结束并销毁,请先下载保存当前结果!
|
(测试中:计划销毁但是现在没有销毁) 退出后,当前会话结束并销毁,请先下载保存当前结果!
|
||||||
</p>
|
</p>
|
||||||
<DevDialogFooter>
|
<DevDialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,6 @@ 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";
|
||||||
};
|
};
|
||||||
|
|
@ -126,7 +125,6 @@ export const ChainOfThoughtStep = memo(
|
||||||
className,
|
className,
|
||||||
icon: Icon = DotIcon,
|
icon: Icon = DotIcon,
|
||||||
label,
|
label,
|
||||||
action,
|
|
||||||
description,
|
description,
|
||||||
status = "complete",
|
status = "complete",
|
||||||
children,
|
children,
|
||||||
|
|
@ -153,10 +151,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
|
|
@ -146,9 +146,14 @@ export const CodeBlockCopyButton = ({
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const { code } = useContext(CodeBlockContext);
|
const { code } = useContext(CodeBlockContext);
|
||||||
|
|
||||||
const handleCopyClick = async () => {
|
const copyToClipboard = async () => {
|
||||||
|
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||||
|
onError?.(new Error("Clipboard API not available"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(code);
|
await navigator.clipboard.writeText(code);
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
onCopy?.();
|
onCopy?.();
|
||||||
setTimeout(() => setIsCopied(false), timeout);
|
setTimeout(() => setIsCopied(false), timeout);
|
||||||
|
|
@ -162,7 +167,7 @@ export const CodeBlockCopyButton = ({
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={cn("shrink-0", className)}
|
className={cn("shrink-0", className)}
|
||||||
onClick={handleCopyClick}
|
onClick={copyToClipboard}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -860,15 +860,12 @@ 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();
|
||||||
|
|
@ -880,10 +877,7 @@ export const PromptInputTextarea = ({
|
||||||
if (isComposing || e.nativeEvent.isComposing) {
|
if (isComposing || e.nativeEvent.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!submitOnEnter) {
|
if (e.shiftKey) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1089,12 +1083,10 @@ export const PromptInputSubmit = ({
|
||||||
controller.attachments.files.length > 0
|
controller.attachments.files.length > 0
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const isStreaming = status === "streaming";
|
// 正在 streaming 时不允许发送
|
||||||
const isSubmitted = status === "submitted";
|
const isStreaming = status === "streaming" || status === "submitted";
|
||||||
// Streaming 时按钮用于停止,不受输入内容是否为空限制
|
|
||||||
const isDisabled = isStreaming
|
const isDisabled = disabled || !hasContent || isStreaming;
|
||||||
? !!disabled
|
|
||||||
: disabled || !hasContent || isSubmitted;
|
|
||||||
|
|
||||||
let Icon = <ArrowUpIcon className="size-4" />;
|
let Icon = <ArrowUpIcon className="size-4" />;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import JSZip from "jszip";
|
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
|
@ -9,15 +8,12 @@ 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,
|
||||||
|
|
@ -99,8 +95,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" || language === "markdown";
|
return (language === "html" && !isWriteFile) || language === "markdown";
|
||||||
}, [language]);
|
}, [isWriteFile, language]);
|
||||||
const artifactUrl = useMemo(() => {
|
const artifactUrl = useMemo(() => {
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -127,8 +123,6 @@ 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) => ({
|
||||||
|
|
@ -151,114 +145,19 @@ 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, resolveMarkdownAssetUrlForDownload]);
|
}, [content, fileName, downloadAsDocx]);
|
||||||
|
|
||||||
// 下载为 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, resolveMarkdownAssetUrlForDownload]);
|
}, [content, fileName, downloadAsPdf]);
|
||||||
|
|
||||||
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(() => {
|
||||||
|
|
@ -286,8 +185,8 @@ export function ArtifactFileDetail({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArtifactHeader className="grid grid-cols-12 gap-3">
|
<ArtifactHeader className="">
|
||||||
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
|
<div className="flex items-center justify-start gap-2">
|
||||||
{previewable && (
|
{previewable && (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
|
|
@ -346,12 +245,10 @@ export function ArtifactFileDetail({
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
)}
|
)}
|
||||||
{/* 仅在代码视图显示缩放控制 */}
|
{/* 放大缩小选择器 */}
|
||||||
{isCodeFile && viewMode === "code" && (
|
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
||||||
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-6 flex min-w-0 items-center justify-center px-1">
|
<div className="flex min-w-0 grow items-center justify-center">
|
||||||
<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">
|
||||||
|
|
@ -366,7 +263,7 @@ export function ArtifactFileDetail({
|
||||||
)}
|
)}
|
||||||
</ArtifactTitle>
|
</ArtifactTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden">
|
<div className="flex items-center justify-end overflow-hidden">
|
||||||
<ArtifactActions>
|
<ArtifactActions>
|
||||||
{isCodeFile && (
|
{isCodeFile && (
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
|
|
@ -440,35 +337,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 asChild>
|
||||||
<DropdownMenuItem
|
<a
|
||||||
onClick={handleDownloadMarkdownBundle}
|
href={urlOfArtifact({
|
||||||
disabled={isPackagingMarkdownBundle}
|
filepath,
|
||||||
className="cursor-pointer"
|
threadId: threadId ?? "",
|
||||||
|
download: true,
|
||||||
|
})}
|
||||||
|
target="_blank"
|
||||||
|
className="w-full cursor-pointer"
|
||||||
>
|
>
|
||||||
{isPackagingMarkdownBundle ? (
|
<DownloadIcon className="size-4" />
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<DownloadIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
{t.common.downloadOriginal}
|
{t.common.downloadOriginal}
|
||||||
</DropdownMenuItem>
|
</a>
|
||||||
) : (
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<a
|
|
||||||
href={urlOfArtifact({
|
|
||||||
filepath,
|
|
||||||
threadId: threadId ?? "",
|
|
||||||
download: true,
|
|
||||||
})}
|
|
||||||
target="_blank"
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-4" />
|
|
||||||
{t.common.downloadOriginal}
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
|
{/* DOCX 和 PDF 导出选项仅对 Markdown 文件显示。 */}
|
||||||
{canConvertToDocxPdf && (
|
{canConvertToDocxPdf && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -593,7 +475,6 @@ export function ArtifactFileDetail({
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
filepath={filepath}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
@ -608,174 +489,40 @@ export function ArtifactFileDetail({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCodeFile && (
|
{!isCodeFile && (
|
||||||
isOfficePreviewKind(artifactPreviewKind) ? (
|
<PreviewIframe
|
||||||
<ArtifactOfficePreview
|
className="size-full border-0"
|
||||||
className="h-full mb-[207px]"
|
containerClassName="h-full mb-[207px]"
|
||||||
kind={artifactPreviewKind}
|
srcDoc={artifactViewerSrcDoc}
|
||||||
artifactUrl={artifactUrl}
|
sandbox="allow-same-origin allow-scripts allow-downloads"
|
||||||
fileName={fileName}
|
title={`Artifact preview: ${fileName}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<PreviewIframe
|
|
||||||
className="size-full border-0"
|
|
||||||
containerClassName="h-full mb-[207px]"
|
|
||||||
srcDoc={artifactViewerSrcDoc}
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-downloads"
|
|
||||||
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, filepath);
|
return rewriteArtifactImagePaths(content ?? "", threadId);
|
||||||
}, [content, threadId, filepath]);
|
}, [content, threadId]);
|
||||||
|
|
||||||
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 CSSProperties}
|
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|
@ -841,276 +588,8 @@ function PreviewIframe({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtifactOfficePreview({
|
function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
||||||
className,
|
if (!threadId || !/\/?mnt\/user-data\//.test(content)) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1118,20 +597,6 @@ function rewriteArtifactImagePaths(
|
||||||
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,
|
||||||
|
|
@ -1139,18 +604,8 @@ function rewriteArtifactImagePaths(
|
||||||
return `})`;
|
return `})`;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const markdownRelativeRewritten = markdownRewritten.replace(
|
|
||||||
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
|
|
||||||
(_full, alt, rawPath) => {
|
|
||||||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
|
||||||
if (!absoluteUrl) {
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
return ``;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
|
const shorthandMarkdownRewritten = markdownRewritten.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 `})`;
|
return `})`;
|
||||||
|
|
@ -1158,16 +613,9 @@ function rewriteArtifactImagePaths(
|
||||||
);
|
);
|
||||||
|
|
||||||
return shorthandMarkdownRewritten.replace(
|
return shorthandMarkdownRewritten.replace(
|
||||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
|
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\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}`;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1178,9 +626,6 @@ type ArtifactPreviewKind =
|
||||||
| "video"
|
| "video"
|
||||||
| "audio"
|
| "audio"
|
||||||
| "pdf"
|
| "pdf"
|
||||||
| "docx"
|
|
||||||
| "xlsx"
|
|
||||||
| "pptx"
|
|
||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
|
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
|
||||||
|
|
@ -1191,22 +636,9 @@ 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("&", "&")
|
.replaceAll("&", "&")
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,11 @@ 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(false);
|
const [autoSelect, setAutoSelect] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(
|
||||||
// const [open, setOpen] = useState(
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||||
// 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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -15,14 +14,10 @@ export function CopyButton({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(() => {
|
||||||
try {
|
void navigator.clipboard.writeText(clipboardData);
|
||||||
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}>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
@ -169,25 +168,15 @@ 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"
|
onClick={() => setOpen(false)}
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
>
|
||||||
onClick={() => setCollapsed((prev) => !prev)}
|
✕
|
||||||
>
|
</button>
|
||||||
{collapsed ? "▢" : "—"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="text-white/70 hover:text-white"
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <div className="space-y-3 p-3">
|
<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>
|
||||||
|
|
@ -302,48 +291,6 @@ 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"
|
||||||
|
|
@ -438,7 +385,7 @@ export function IframeTestPanel() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
Loader2Icon,
|
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
|
|
@ -41,6 +40,7 @@ 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,8 +130,7 @@ export function InputBox({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
const iframeSkill = useIframeSkill();
|
||||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
|
||||||
|
|
||||||
const threadId = threadIdFromProps;
|
const threadId = threadIdFromProps;
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
|
|
@ -327,7 +326,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={isInputDisabled}
|
disabled={disabled}
|
||||||
globalDrop
|
globalDrop
|
||||||
multiple
|
multiple
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|
@ -342,7 +341,7 @@ export function InputBox({
|
||||||
"size-full",
|
"size-full",
|
||||||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||||||
)}
|
)}
|
||||||
disabled={isInputDisabled}
|
disabled={disabled}
|
||||||
placeholder={t.inputBox.placeholder}
|
placeholder={t.inputBox.placeholder}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={initialValue}
|
defaultValue={initialValue}
|
||||||
|
|
@ -365,7 +364,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 className="min-w-0 flex-1">
|
<PromptInputTools>
|
||||||
{/* TODO: Add more connectors here
|
{/* TODO: Add more connectors here
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className="px-2!" />
|
<PromptInputActionMenuTrigger className="px-2!" />
|
||||||
|
|
@ -378,8 +377,7 @@ export function InputBox({
|
||||||
<AddAttachmentsButton className="px-2!" />
|
<AddAttachmentsButton className="px-2!" />
|
||||||
<IframeSkillDialogButton
|
<IframeSkillDialogButton
|
||||||
className="px-2!"
|
className="px-2!"
|
||||||
selectedSkills={iframeSkill.selectedSkills}
|
selectedSkill={iframeSkill.selectedSkill}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
|
||||||
openSkillDialog={iframeSkill.openSkillDialog}
|
openSkillDialog={iframeSkill.openSkillDialog}
|
||||||
clearSkill={iframeSkill.clearSkill}
|
clearSkill={iframeSkill.clearSkill}
|
||||||
/>
|
/>
|
||||||
|
|
@ -423,7 +421,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={isInputDisabled}
|
disabled={disabled}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
|
|
@ -431,8 +429,8 @@ export function InputBox({
|
||||||
|
|
||||||
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
||||||
<SuggestionListContainer
|
<SuggestionListContainer
|
||||||
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
threadId={threadId}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
sendSelectSkill={iframeSkill.sendSelectSkill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -496,37 +494,29 @@ export function InputBox({
|
||||||
|
|
||||||
// SuggestionList 容器
|
// SuggestionList 容器
|
||||||
function SuggestionListContainer({
|
function SuggestionListContainer({
|
||||||
bootstrapAndLockSkills,
|
threadId,
|
||||||
isBootstrapping,
|
sendSelectSkill,
|
||||||
}: {
|
}: {
|
||||||
bootstrapAndLockSkills: (params: {
|
threadId: string;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
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
|
<SuggestionList threadId={threadId} sendSelectSkill={sendSelectSkill} />
|
||||||
bootstrapAndLockSkills={bootstrapAndLockSkills}
|
|
||||||
isBootstrapping={isBootstrapping}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快速选择skillbutton
|
// 快速选择skillbutton
|
||||||
function SuggestionList({
|
function SuggestionList({
|
||||||
bootstrapAndLockSkills,
|
threadId,
|
||||||
isBootstrapping,
|
sendSelectSkill,
|
||||||
}: {
|
}: {
|
||||||
bootstrapAndLockSkills: (params: {
|
threadId: string;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
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(
|
||||||
|
|
@ -545,32 +535,50 @@ function SuggestionList({
|
||||||
suggestion: string;
|
suggestion: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (isBootstrapping) return;
|
const languageTypeRaw =
|
||||||
|
searchParams.get("languageType")?.trim() ??
|
||||||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
searchParams.get("language_type")?.trim();
|
||||||
const childSkills = (suggestion.children ?? [])
|
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
|
||||||
.map((item) => ({
|
const bootstrapByIds = (ids: string[]) => {
|
||||||
id: String(item.id).trim(),
|
const content_ids = Array.from(
|
||||||
name: item.name?.trim() ?? "",
|
new Set(
|
||||||
}))
|
ids
|
||||||
.filter((item): item is { id: string; name: string } =>
|
.map((id) => Number(id))
|
||||||
Boolean(item.id) && Boolean(item.name),
|
.filter((id) => Number.isFinite(id) && id > 0),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (childSkills.length > 0) {
|
if (!threadId || content_ids.length === 0) return;
|
||||||
void bootstrapAndLockSkills({
|
void bootstrapRemoteSkill({
|
||||||
selectedSkills: childSkills,
|
thread_id: threadId,
|
||||||
title: suggestion.suggestion,
|
content_ids,
|
||||||
|
language_type: languageType,
|
||||||
|
target_dir: "/mnt/user-data/uploads/skill",
|
||||||
|
clear_target: true,
|
||||||
});
|
});
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页
|
||||||
void bootstrapAndLockSkills({
|
const childSkillIds = (suggestion.children ?? [])
|
||||||
selectedSkills: suggestion.skill_id.map((id) => ({
|
.map((item) => String(item.id).trim())
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (childSkillIds.length > 0) {
|
||||||
|
sendSelectSkill(
|
||||||
|
childSkillIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
name: suggestion.suggestion,
|
name: suggestion.suggestion,
|
||||||
})),
|
})),
|
||||||
title: suggestion.suggestion,
|
);
|
||||||
});
|
bootstrapByIds(childSkillIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
||||||
|
sendSelectSkill(
|
||||||
|
suggestion.skill_id.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: suggestion.suggestion,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
bootstrapByIds(suggestion.skill_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 原有逻辑
|
// 原有逻辑
|
||||||
|
|
@ -590,7 +598,7 @@ function SuggestionList({
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
[textInput, sendSelectSkill, threadId, searchParams],
|
||||||
);
|
);
|
||||||
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">
|
||||||
|
|
@ -639,24 +647,22 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||||
// 启动iframeSkillDialog
|
// 启动iframeSkillDialog
|
||||||
function IframeSkillDialogButton({
|
function IframeSkillDialogButton({
|
||||||
className,
|
className,
|
||||||
selectedSkills,
|
selectedSkill,
|
||||||
isBootstrapping,
|
|
||||||
openSkillDialog,
|
openSkillDialog,
|
||||||
clearSkill,
|
clearSkill,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedSkills: Array<{ skill_id: string; title: string }>;
|
selectedSkill: { skill_id: string; title: string } | null;
|
||||||
isBootstrapping: boolean;
|
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip content={t.inputBox.selectSkill}>
|
<Tooltip content={t.inputBox.selectSkill}>
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
className={cn("group shrink-0 px-2! hover:bg-[#EAE2F5]", className)}
|
className={cn("group px-2! hover:bg-[#EAE2F5]", className)}
|
||||||
onClick={openSkillDialog}
|
onClick={openSkillDialog}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -672,35 +678,20 @@ function IframeSkillDialogButton({
|
||||||
</svg>
|
</svg>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{isBootstrapping ? (
|
{selectedSkill && (
|
||||||
<Tag className="bg-background text-muted-foreground gap-2 border">
|
<Badge
|
||||||
<Loader2Icon className="size-3 animate-spin" />
|
variant="secondary"
|
||||||
{t.common.loading}
|
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
|
||||||
</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;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{selectedSkills.map((skill, index) => (
|
{selectedSkill.title}
|
||||||
<Tag key={`${skill.skill_id}-${skill.title}-${index}`} className="shrink-0">
|
<button
|
||||||
{skill.title}
|
onClick={clearSkill}
|
||||||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||||
<button
|
>
|
||||||
onClick={() => clearSkill(skill.skill_id)}
|
<XIcon className="size-3" />
|
||||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
</button>
|
||||||
type="button"
|
</Badge>
|
||||||
>
|
)}
|
||||||
<XIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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,
|
||||||
|
|
@ -40,8 +39,6 @@ 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,
|
||||||
|
|
@ -79,9 +76,6 @@ 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
|
||||||
|
|
@ -93,17 +87,14 @@ 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={(event) => {
|
onClick={() => setShowAbove(!showAbove)}
|
||||||
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(totalToolStepCount)}
|
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
icon={
|
icon={
|
||||||
|
|
@ -117,7 +108,7 @@ export function MessageGroup({
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{shouldShowToolSteps && (
|
{lastToolCallStep && (
|
||||||
<ChainOfThoughtContent className="px-4 pb-2">
|
<ChainOfThoughtContent className="px-4 pb-2">
|
||||||
{showAbove &&
|
{showAbove &&
|
||||||
aboveLastToolCallSteps.map((step) =>
|
aboveLastToolCallSteps.map((step) =>
|
||||||
|
|
@ -154,10 +145,7 @@ 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={(event) => {
|
onClick={() => setShowLastThinking(!showLastThinking)}
|
||||||
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
|
||||||
|
|
@ -215,33 +203,6 @@ 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;
|
||||||
|
|
@ -416,31 +377,19 @@ 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 && (
|
||||||
<ExpandableToolContent content={command} expanded={isCommandExpanded} />
|
<CodeBlock
|
||||||
|
className="mx-0 cursor-pointer border-none px-0"
|
||||||
|
showLineNumbers={false}
|
||||||
|
language="bash"
|
||||||
|
code={command}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ 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();
|
||||||
|
|
@ -120,7 +119,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 copyToClipboard(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success(t.clipboard.linkCopied);
|
toast.success(t.clipboard.linkCopied);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||||
|
|
@ -179,7 +178,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)}?is_chatting=true`}
|
href={pathOfThread(thread.thread_id)}
|
||||||
>
|
>
|
||||||
{titleOfThread(thread)}
|
{titleOfThread(thread)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ 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">
|
||||||
{/* TODO: 测试标识 */}
|
XClaw(测试专用侧边栏。)
|
||||||
XClaw <span className="text-sm text-[#000000c5]">v3.2.1 fix(frontend): 进入/new预创建会话并强制跳转聊天态</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
|
|
|
||||||
|
|
@ -277,8 +277,6 @@ 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",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,8 +208,6 @@ export interface Translations {
|
||||||
writeFile: string;
|
writeFile: string;
|
||||||
clickToViewContent: string;
|
clickToViewContent: string;
|
||||||
writeTodos: string;
|
writeTodos: string;
|
||||||
expandContent: string;
|
|
||||||
collapseContent: string;
|
|
||||||
skillInstallTooltip: string;
|
skillInstallTooltip: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
import type { Translations } from "./types";
|
import type { Translations } from "./types";
|
||||||
|
|
||||||
export const zhCN: Translations = {
|
export const zhCN: Translations = {
|
||||||
// 隐蔽版本标识:Tag:v3.2.1 feat: 宿主页复制
|
|
||||||
// Locale meta
|
// Locale meta
|
||||||
locale: {
|
locale: {
|
||||||
localName: "中文",
|
localName: "中文",
|
||||||
|
|
@ -58,7 +57,8 @@ export const zhCN: Translations = {
|
||||||
|
|
||||||
// Welcome
|
// Welcome
|
||||||
welcome: {
|
welcome: {
|
||||||
greeting: "轻办公 · XClaw",
|
// TODO: 测试环境标识
|
||||||
|
greeting: "轻办公 · XClaw Tag:v3.2.0 --- Skill功能施工中 --- refactor(frontend): 将 SELECT_SKILL 重命名为 SELECT_SKILLS.",
|
||||||
description:
|
description:
|
||||||
"欢迎使用 🦌 DeerFlow,一个完全开源的超级智能体。通过内置和自定义的 Skills,\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等,几乎可以做任何事情。",
|
"欢迎使用 🦌 DeerFlow,一个完全开源的超级智能体。通过内置和自定义的 Skills,\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等,几乎可以做任何事情。",
|
||||||
|
|
||||||
|
|
@ -265,8 +265,6 @@ export const zhCN: Translations = {
|
||||||
writeFile: "写入文件",
|
writeFile: "写入文件",
|
||||||
clickToViewContent: "点击查看文件内容",
|
clickToViewContent: "点击查看文件内容",
|
||||||
writeTodos: "更新 To-do 列表",
|
writeTodos: "更新 To-do 列表",
|
||||||
expandContent: "展开",
|
|
||||||
collapseContent: "收起",
|
|
||||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ 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 选择对话框
|
||||||
|
|
@ -23,8 +21,6 @@ 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;
|
||||||
|
|
||||||
// 消息类型
|
// 消息类型
|
||||||
|
|
@ -44,11 +40,6 @@ 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[];
|
||||||
|
|
@ -89,31 +80,11 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,5 @@ export function getLocalSettings(): LocalSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLocalSettings(settings: LocalSettings) {
|
export function saveLocalSettings(settings: LocalSettings) {
|
||||||
void settings;
|
localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
// 注释了,因为本地存储会污染模型配置
|
|
||||||
console.log("localStorage设置,已经注释");
|
|
||||||
localStorage.removeItem(LOCAL_SETTINGS_KEY);
|
|
||||||
// localStorage.setItem(LOCAL_SETTINGS_KEY, JSON.stringify(settings));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,83 +46,27 @@ 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 {
|
||||||
const directMessage = readMessageCandidate(error);
|
if (typeof error === "string" && error.trim()) {
|
||||||
if (directMessage) {
|
return error;
|
||||||
return directMessage;
|
|
||||||
}
|
}
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
const visited = new Set<object>();
|
return error.message;
|
||||||
const queue: unknown[] = [error];
|
}
|
||||||
const preferredKeys = ["message", "detail", "error"];
|
if (typeof error === "object" && error !== null) {
|
||||||
|
const message = Reflect.get(error, "message");
|
||||||
while (queue.length > 0) {
|
if (typeof message === "string" && message.trim()) {
|
||||||
const current = queue.shift();
|
|
||||||
if (current == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = readMessageCandidate(current);
|
|
||||||
if (message) {
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
const nestedError = Reflect.get(error, "error");
|
||||||
if (typeof current !== "object") {
|
if (nestedError instanceof Error && nestedError.message.trim()) {
|
||||||
continue;
|
return nestedError.message;
|
||||||
}
|
}
|
||||||
|
if (typeof nestedError === "string" && nestedError.trim()) {
|
||||||
if (visited.has(current)) {
|
return nestedError;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(current)) {
|
|
||||||
queue.push(...current);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const value of Object.values(current)) {
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
queue.push(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return "Request failed.";
|
||||||
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({
|
||||||
|
|
@ -198,9 +142,6 @@ 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,
|
||||||
|
|
@ -214,14 +155,12 @@ export function useThreadStream({
|
||||||
}, [onStart, onFinish, onToolEnd]);
|
}, [onStart, onFinish, onToolEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedThreadId = normalizeThreadId(threadId) ?? null;
|
const normalizedThreadId = 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]);
|
||||||
|
|
||||||
|
|
@ -232,23 +171,6 @@ 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;
|
||||||
|
|
@ -328,7 +250,7 @@ export function useThreadStream({
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
setOptimisticMessages([]);
|
setOptimisticMessages([]);
|
||||||
showStreamErrorToast(error);
|
toast.error(getStreamErrorMessage(error));
|
||||||
},
|
},
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
listeners.current.onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
|
|
@ -353,13 +275,6 @@ 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,
|
||||||
|
|
@ -373,9 +288,7 @@ export function useThreadStream({
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const resolvedThreadId =
|
const resolvedThreadId =
|
||||||
normalizeThreadId(threadId) ??
|
threadId ?? threadIdRef.current ?? undefined;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import {
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextRun,
|
TextRun,
|
||||||
HeadingLevel,
|
HeadingLevel,
|
||||||
ImageRun,
|
|
||||||
type ParagraphChild,
|
|
||||||
} from "docx";
|
} from "docx";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
|
@ -59,10 +57,6 @@ export interface DocxOptions {
|
||||||
* @default 22 (11pt)
|
* @default 22 (11pt)
|
||||||
*/
|
*/
|
||||||
codeFontSize?: number;
|
codeFontSize?: number;
|
||||||
/**
|
|
||||||
* 解析 Markdown 里的资源路径(如图片相对路径)
|
|
||||||
*/
|
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -86,18 +80,10 @@ export async function downloadMarkdownAsDocx(
|
||||||
filename: string,
|
filename: string,
|
||||||
options: DocxOptions = {},
|
options: DocxOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const { codeFont = "Courier New", codeFontSize = 22 } = options;
|
||||||
codeFont = "Courier New",
|
|
||||||
codeFontSize = 22,
|
|
||||||
resolveAssetUrl,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const tokens = marked.lexer(markdown);
|
const tokens = marked.lexer(markdown);
|
||||||
const children = await parseTokensToDocx(tokens, {
|
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
|
||||||
codeFont,
|
|
||||||
codeFontSize,
|
|
||||||
resolveAssetUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const doc = new DocxDocument({
|
const doc = new DocxDocument({
|
||||||
sections: [{ children }],
|
sections: [{ children }],
|
||||||
|
|
@ -126,9 +112,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -137,16 +121,10 @@ 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(normalizedMarkdown);
|
const htmlContent = await marked.parse(markdown);
|
||||||
|
|
||||||
// 创建容器并应用样式
|
// 创建容器并应用样式
|
||||||
const container = createStyledContainer(htmlContent);
|
const container = createStyledContainer(htmlContent);
|
||||||
|
|
@ -331,17 +309,16 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
|
||||||
/**
|
/**
|
||||||
* 解析 Markdown Token 为 DOCX Paragraph
|
* 解析 Markdown Token 为 DOCX Paragraph
|
||||||
*/
|
*/
|
||||||
async function parseTokensToDocx(
|
function parseTokensToDocx(
|
||||||
tokens: MarkdownToken[],
|
tokens: MarkdownToken[],
|
||||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
options: Required<DocxOptions>,
|
||||||
Pick<DocxOptions, "resolveAssetUrl">,
|
): Paragraph[] {
|
||||||
): 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 = await parseInlineTokens(token.tokens ?? [], options);
|
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||||
paragraphs.push(
|
paragraphs.push(
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
children: runs,
|
children: runs,
|
||||||
|
|
@ -353,7 +330,7 @@ async function parseTokensToDocx(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "paragraph": {
|
case "paragraph": {
|
||||||
const runs = await parseInlineTokens(token.tokens ?? [], options);
|
const runs = 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("")],
|
||||||
|
|
@ -384,8 +361,8 @@ async function parseTokensToDocx(
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
for (const item of token.items ?? []) {
|
token.items?.forEach((item: MarkdownToken) => {
|
||||||
const runs = await parseInlineTokens(
|
const runs = parseInlineTokens(
|
||||||
item.tokens?.[0]?.tokens ?? [],
|
item.tokens?.[0]?.tokens ?? [],
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
@ -396,12 +373,12 @@ async function parseTokensToDocx(
|
||||||
spacing: { after: 80 },
|
spacing: { after: 80 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const runs = await parseInlineTokens(
|
const runs = parseInlineTokens(
|
||||||
token.tokens?.[0]?.tokens ?? [],
|
token.tokens?.[0]?.tokens ?? [],
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
@ -430,19 +407,6 @@ async 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,12 +416,11 @@ async function parseTokensToDocx(
|
||||||
/**
|
/**
|
||||||
* 解析行内 Token 为 TextRun
|
* 解析行内 Token 为 TextRun
|
||||||
*/
|
*/
|
||||||
async function parseInlineTokens(
|
function parseInlineTokens(
|
||||||
tokens: MarkdownToken[],
|
tokens: MarkdownToken[],
|
||||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
options: Required<DocxOptions>,
|
||||||
Pick<DocxOptions, "resolveAssetUrl">,
|
): TextRun[] {
|
||||||
): Promise<ParagraphChild[]> {
|
const runs: TextRun[] = [];
|
||||||
const runs: ParagraphChild[] = [];
|
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
|
|
@ -497,14 +460,6 @@ async 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 ?? ""));
|
||||||
}
|
}
|
||||||
|
|
@ -513,155 +468,6 @@ async 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],
|
|
||||||
``,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取标题级别
|
* 获取标题级别
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import {
|
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||||
downloadMarkdownAsDocx,
|
|
||||||
downloadMarkdownAsPdf,
|
|
||||||
type DocxOptions,
|
|
||||||
type PdfOptions,
|
|
||||||
} from "./converter";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown 下载 Hook 配置选项
|
* Markdown 下载 Hook 配置选项
|
||||||
|
|
@ -36,21 +31,11 @@ export interface UseMarkdownDownloadReturn {
|
||||||
/**
|
/**
|
||||||
* 下载为 DOCX
|
* 下载为 DOCX
|
||||||
*/
|
*/
|
||||||
downloadAsDocx: (
|
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
|
||||||
markdown: string,
|
|
||||||
filename: string,
|
|
||||||
options?: DocxOptions,
|
|
||||||
) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* 下载为 PDF
|
* 下载为 PDF
|
||||||
*/
|
*/
|
||||||
downloadAsPdf: (
|
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
|
||||||
markdown: string,
|
|
||||||
filename: string,
|
|
||||||
options?: PdfOptions & {
|
|
||||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
|
||||||
},
|
|
||||||
) => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* 是否可以下载(没有正在进行的下载)
|
* 是否可以下载(没有正在进行的下载)
|
||||||
*/
|
*/
|
||||||
|
|
@ -97,14 +82,14 @@ export function useMarkdownDownload(
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadAsDocx = useCallback(
|
const downloadAsDocx = useCallback(
|
||||||
async (markdown: string, filename: string, options?: DocxOptions) => {
|
async (markdown: string, filename: string) => {
|
||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
|
|
||||||
setIsDownloading("docx");
|
setIsDownloading("docx");
|
||||||
onDownloadStart?.("docx");
|
onDownloadStart?.("docx");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadMarkdownAsDocx(markdown, filename, options);
|
await downloadMarkdownAsDocx(markdown, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError?.(
|
onError?.(
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
|
@ -119,20 +104,14 @@ export function useMarkdownDownload(
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadAsPdf = useCallback(
|
const downloadAsPdf = useCallback(
|
||||||
async (
|
async (markdown: string, filename: string) => {
|
||||||
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, options);
|
await downloadMarkdownAsPdf(markdown, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError?.(
|
onError?.(
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
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 {
|
||||||
|
|
@ -18,113 +15,22 @@ 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: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseIframeSkillOptions {
|
export function useIframeSkill(): UseIframeSkillReturn {
|
||||||
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) 回滚 localStorage(latest + 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。
|
||||||
|
|
@ -137,80 +43,31 @@ export function useIframeSkill(
|
||||||
// }, [searchParams]);
|
// }, [searchParams]);
|
||||||
|
|
||||||
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
|
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// if (!threadId) return;
|
if (!threadIdFromQuery) return;
|
||||||
// if (isChattingFromQuery !== "true") return;
|
if (isChattingFromQuery !== "true") return;
|
||||||
// if (lastThreadIdRef.current === threadId) return;
|
if (lastThreadIdRef.current === threadIdFromQuery) return;
|
||||||
// lastThreadIdRef.current = threadId;
|
lastThreadIdRef.current = threadIdFromQuery;
|
||||||
// router.replace(`/workspace/chats/${threadId}`);
|
router.replace(`/workspace/chats/${threadIdFromQuery}`);
|
||||||
// }, [isChattingFromQuery, router, threadId]);
|
}, [isChattingFromQuery, router, threadIdFromQuery]);
|
||||||
|
|
||||||
// 2. 监听宿主页 postMessage
|
// 2. 监听宿主页 postMessage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (isSelectedSkillMessage(event.data)) {
|
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
const { id, title } = event.data;
|
|
||||||
const singleSkill = { skill_id: String(id), title };
|
|
||||||
setSelectedSkill(singleSkill);
|
|
||||||
setSelectedSkills([singleSkill]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSelectedSkillsMessage(event.data)) {
|
if (!isSelectedSkillMessage(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);
|
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const { id, title } = event.data;
|
||||||
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
};
|
};
|
||||||
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 };
|
||||||
|
|
@ -218,94 +75,6 @@ export function useIframeSkill(
|
||||||
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 = {
|
||||||
|
|
@ -317,66 +86,13 @@ export function useIframeSkill(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 清除选中并发送空 selectedSkills 数组给主页
|
// 清除选中并发送空 selectedSkills 数组给主页
|
||||||
const clearSkill = useCallback(
|
const clearSkill = useCallback(() => {
|
||||||
(skillId?: string) => {
|
setSelectedSkill(null);
|
||||||
const removeAll = !skillId;
|
// 发送空数组给主页,通知取消选择
|
||||||
const nextSelectedSkills = removeAll
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||||
? []
|
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||||
: selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
|
sendToParent(message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
setSelectedSkills(nextSelectedSkills);
|
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@ 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 {
|
import { isSelectedSkillMessage } from "@/core/iframe-messages";
|
||||||
isSelectedSkillMessage,
|
|
||||||
isSelectedSkillsMessage,
|
|
||||||
type SelectedSkillPayloadItem,
|
|
||||||
} from "@/core/iframe-messages";
|
|
||||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||||
|
|
||||||
/** 技能基础数据 */
|
/** 技能基础数据 */
|
||||||
|
|
@ -55,20 +51,14 @@ export function useSelectedSkillListener({
|
||||||
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
const skillBootstrappedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const performBootstrap = useCallback(
|
const performBootstrap = useCallback(
|
||||||
async (skills: SelectedSkillPayloadItem[], title: string) => {
|
async (id: number | string, title: string) => {
|
||||||
if (!threadId) return;
|
if (!threadId) return;
|
||||||
const contentIds = Array.from(
|
const contentId = Number(id);
|
||||||
new Set(
|
if (!Number.isFinite(contentId) || contentId <= 0) {
|
||||||
skills
|
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id);
|
||||||
.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 数组",
|
message: `非法 skill id: ${String(id)}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -78,13 +68,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}:${contentIds.join(",")}:${languageType}`;
|
const initKey = `${threadId}:${id}:${languageType}`;
|
||||||
if (skillBootstrappedKeyRef.current === initKey) {
|
if (skillBootstrappedKeyRef.current === initKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
|
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`,
|
||||||
);
|
);
|
||||||
setIsBootstrapping(true);
|
setIsBootstrapping(true);
|
||||||
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
|
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
|
||||||
|
|
@ -92,7 +82,7 @@ export function useSelectedSkillListener({
|
||||||
try {
|
try {
|
||||||
const result = await bootstrapRemoteSkill({
|
const result = await bootstrapRemoteSkill({
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
content_ids: contentIds,
|
content_ids: [contentId],
|
||||||
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,
|
||||||
|
|
@ -133,39 +123,23 @@ 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(
|
void performBootstrap(skillIdFromQuery, titleFromQuery);
|
||||||
[{ 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)) {
|
if (!isSelectedSkillMessage(event.data)) return;
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
const { id, title } = data;
|
|
||||||
console.log(
|
|
||||||
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
setSelectedSkill({ skill_id: String(id), title });
|
|
||||||
void performBootstrap([{ id, name: title }], title);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelectedSkillsMessage(event.data)) {
|
const { id, title } = data;
|
||||||
const { selectedSkills } = event.data;
|
console.log(
|
||||||
if (!selectedSkills.length) return;
|
"[useSelectedSkillListener] 收到 postMessage selectedSkill:",
|
||||||
const first = selectedSkills[0]!;
|
data,
|
||||||
const firstTitle = first.name;
|
);
|
||||||
console.log(
|
|
||||||
"[useSelectedSkillListener] 收到 postMessage selectedSkills:",
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
event.data,
|
void performBootstrap(id, title);
|
||||||
);
|
|
||||||
setSelectedSkill({ skill_id: String(first.id), title: firstTitle });
|
|
||||||
void performBootstrap(selectedSkills, firstTitle);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[performBootstrap],
|
[performBootstrap],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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));
|
||||||
|
|
@ -19,14 +18,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: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
type: "copyToClipboard",
|
||||||
text,
|
text,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
if (isInIframe) {
|
if (isInIframe && window.parent) {
|
||||||
try {
|
try {
|
||||||
// Request parent window to copy
|
// Request parent window to copy
|
||||||
sendToParent(message);
|
window.parent.postMessage(message, "*");
|
||||||
console.log(
|
console.log(
|
||||||
"[copyToClipboard] iframe mode → postMessage to parent",
|
"[copyToClipboard] iframe mode → postMessage to parent",
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -411,12 +411,6 @@
|
||||||
--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 属性选择器统一定义
|
||||||
|
|
@ -458,9 +452,9 @@ pre {
|
||||||
font-size: calc(16px * var(--zoom-scale));
|
font-size: calc(16px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代码块 - 14px */
|
/* 二三级标题 - 16px */
|
||||||
[data-streamdown="code-block"] pre {
|
[data-streamdown="code-block"] pre {
|
||||||
font-size: calc(14px * var(--zoom-scale));
|
font-size: calc(16px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-line {
|
.cm-line {
|
||||||
|
|
@ -491,11 +485,3 @@ pre {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pptx-preview-wrap {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pptx-preview-wrap .pptx-preview-wrapper {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
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,
|
||||||
|
|
@ -89,22 +87,8 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,32 +117,4 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
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");
|
||||||
|
|
@ -36,67 +29,4 @@ 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 });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue