Compare commits
11 Commits
20b48f6185
...
fe40a1b479
| Author | SHA1 | Date |
|---|---|---|
|
|
fe40a1b479 | |
|
|
2deeb9f967 | |
|
|
e5c0e9d584 | |
|
|
5a7de8b148 | |
|
|
f4af52a24a | |
|
|
72a92707ca | |
|
|
a9f98aaf71 | |
|
|
0e7412bab4 | |
|
|
51e795a289 | |
|
|
c2ddb1cee5 | |
|
|
39d2807bcf |
|
|
@ -269,7 +269,8 @@ You: "Deploying to staging..." [proceed]
|
|||
- 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
|
||||
- All temporary work happens in `/mnt/user-data/workspace`
|
||||
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_file` tool
|
||||
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
||||
- MANDATORY delivery sequence for Markdown/HTML outputs: after `write_file` (or `str_replace`) creates/updates a deliverable `.md` or `.html` in `/mnt/user-data/outputs`, you MUST call `present_files` for that file before finishing your response
|
||||
{acp_section}
|
||||
</working_directory>
|
||||
|
||||
|
|
@ -347,8 +348,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
|||
{subagent_reminder}- Skill First: Always load the relevant skill before starting **complex** tasks.
|
||||
- Progressive Loading: Load resources incrementally as referenced in skills
|
||||
- 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
|
||||
- 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
|
||||
- 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.
|
||||
|
|
@ -495,7 +496,7 @@ def _build_acp_section() -> str:
|
|||
"- ACP agents (e.g. codex, claude_code) run in their own independent workspace — NOT in `/mnt/user-data/`\n"
|
||||
"- 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"
|
||||
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_file`"
|
||||
"- To deliver ACP output to the user: copy from `/mnt/acp-workspace/<file>` to `/mnt/user-data/outputs/<file>`, then use `present_files`"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -63,11 +63,13 @@
|
|||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.6.1",
|
||||
"docx-preview": "^0.3.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"gsap": "^3.13.0",
|
||||
"hast": "^1.0.0",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"marked": "^17.0.5",
|
||||
|
|
@ -79,6 +81,7 @@
|
|||
"nextra-theme-docs": "^4.6.1",
|
||||
"nuxt-og-image": "^5.1.13",
|
||||
"ogl": "^1.0.11",
|
||||
"pptx-preview": "^1.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
|
|
@ -94,6 +97,7 @@
|
|||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ importers:
|
|||
docx:
|
||||
specifier: ^9.6.1
|
||||
version: 9.6.1
|
||||
docx-preview:
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.4
|
||||
|
|
@ -152,6 +155,9 @@ importers:
|
|||
html2pdf.js:
|
||||
specifier: ^0.14.0
|
||||
version: 0.14.0
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
katex:
|
||||
specifier: ^0.16.28
|
||||
version: 0.16.28
|
||||
|
|
@ -185,6 +191,9 @@ importers:
|
|||
ogl:
|
||||
specifier: ^1.0.11
|
||||
version: 1.0.11
|
||||
pptx-preview:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
|
|
@ -230,6 +239,9 @@ importers:
|
|||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
|
|
@ -2548,6 +2560,10 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
ai@6.0.78:
|
||||
resolution: {integrity: sha512-eriIX/NLWfWNDeE/OJy8wmIp9fyaH7gnxTOCPT5bp0MNkvORstp1TwRUql9au8XjXzH7o2WApqbwgxJDDV0Rbw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2803,6 +2819,10 @@ packages:
|
|||
ccount@2.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2875,6 +2895,10 @@ packages:
|
|||
codemirror@6.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
|
||||
|
||||
|
|
@ -2934,6 +2958,11 @@ packages:
|
|||
cose-base@2.2.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
|
|
@ -3206,6 +3235,9 @@ packages:
|
|||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
docx@9.6.1:
|
||||
resolution: {integrity: sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3221,6 +3253,9 @@ packages:
|
|||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@5.6.0:
|
||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||
|
||||
embla-carousel-react@8.6.0:
|
||||
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
|
||||
peerDependencies:
|
||||
|
|
@ -3553,6 +3588,10 @@ packages:
|
|||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
|
||||
frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
framer-motion@12.34.0:
|
||||
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
|
||||
peerDependencies:
|
||||
|
|
@ -4190,6 +4229,9 @@ packages:
|
|||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
|
|
@ -4818,6 +4860,9 @@ packages:
|
|||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
pptx-preview@1.0.7:
|
||||
resolution: {integrity: sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -5274,6 +5319,10 @@ packages:
|
|||
resolution: {integrity: sha512-SBMgkuJYvP4F62daRfBNwYC2nXTEhNXAfsBZ/BB7Ly85/KnbnjmKM7/45ZrFbH6jIMiAliDUDPSZFUuXDvcg6A==}
|
||||
hasBin: true
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
|
|
@ -5449,6 +5498,9 @@ packages:
|
|||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
|
|
@ -5800,10 +5852,23 @@ packages:
|
|||
wicked-good-xpath@1.3.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||
hasBin: true
|
||||
|
|
@ -5836,6 +5901,9 @@ packages:
|
|||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zrender@5.6.1:
|
||||
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
|
|
@ -8234,6 +8302,8 @@ snapshots:
|
|||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
adler-32@1.3.1: {}
|
||||
|
||||
ai@6.0.78(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.39(zod@3.25.76)
|
||||
|
|
@ -8476,6 +8546,11 @@ snapshots:
|
|||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
|
@ -8564,6 +8639,8 @@ snapshots:
|
|||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.13
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
collapse-white-space@2.1.0: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
@ -8609,6 +8686,8 @@ snapshots:
|
|||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
|
|
@ -8899,6 +8978,10 @@ snapshots:
|
|||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
docx-preview@0.3.7:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
docx@9.6.1:
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
|
|
@ -8920,6 +9003,11 @@ snapshots:
|
|||
es-errors: 1.3.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):
|
||||
dependencies:
|
||||
embla-carousel: 8.6.0
|
||||
|
|
@ -9449,6 +9537,8 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
motion-dom: 12.34.0
|
||||
|
|
@ -10154,6 +10244,8 @@ snapshots:
|
|||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -11179,6 +11271,14 @@ snapshots:
|
|||
picocolors: 1.1.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: {}
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1):
|
||||
|
|
@ -11771,6 +11871,10 @@ snapshots:
|
|||
commander: 13.1.0
|
||||
wicked-good-xpath: 1.3.0
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
|
|
@ -11966,6 +12070,8 @@ snapshots:
|
|||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
|
@ -12335,8 +12441,22 @@ snapshots:
|
|||
|
||||
wicked-good-xpath@1.3.0: {}
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
sax: 1.6.0
|
||||
|
|
@ -12357,6 +12477,10 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
|
|
|||
|
|
@ -452,9 +452,9 @@ export default function ChatPage() {
|
|||
{t.common.artifacts}
|
||||
</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<main className="min-h-0 grow overflow-auto">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
className="max-w-(--container-width-sm) p-4 pt-12 mb-[207px]"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export const ChainOfThoughtHeader = memo(
|
|||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon | React.ReactElement;
|
||||
label: ReactNode;
|
||||
action?: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
|
@ -125,6 +126,7 @@ export const ChainOfThoughtStep = memo(
|
|||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
action,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
|
|
@ -151,7 +153,10 @@ export const ChainOfThoughtStep = memo(
|
|||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>{label}</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
|
|
@ -146,14 +146,9 @@ export const CodeBlockCopyButton = ({
|
|||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleCopyClick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
await copyToClipboard(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
|
|
@ -167,7 +162,7 @@ export const CodeBlockCopyButton = ({
|
|||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
onClick={handleCopyClick}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -883,7 +883,7 @@ export const PromptInputTextarea = ({
|
|||
if (!submitOnEnter) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
|
@ -1089,10 +1089,12 @@ export const PromptInputSubmit = ({
|
|||
controller.attachments.files.length > 0
|
||||
: false;
|
||||
|
||||
// 正在 streaming 时不允许发送
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
const isDisabled = disabled || !hasContent || isStreaming;
|
||||
const isStreaming = status === "streaming";
|
||||
const isSubmitted = status === "submitted";
|
||||
// Streaming 时按钮用于停止,不受输入内容是否为空限制
|
||||
const isDisabled = isStreaming
|
||||
? !!disabled
|
||||
: disabled || !hasContent || isSubmitted;
|
||||
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import JSZip from "jszip";
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
|
|
@ -8,12 +9,15 @@ import {
|
|||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ComponentProps,
|
||||
type HTMLAttributes,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import {
|
||||
Artifact,
|
||||
|
|
@ -95,8 +99,8 @@ export function ArtifactFileDetail({
|
|||
return checkCodeFile(filepath);
|
||||
}, [filepath, isWriteFile, isSkillFile]);
|
||||
const previewable = useMemo(() => {
|
||||
return (language === "html" && !isWriteFile) || language === "markdown";
|
||||
}, [isWriteFile, language]);
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const artifactUrl = useMemo(() => {
|
||||
if (!threadId) {
|
||||
return "";
|
||||
|
|
@ -123,6 +127,8 @@ export function ArtifactFileDetail({
|
|||
});
|
||||
|
||||
const displayContent = content ?? "";
|
||||
const [isPackagingMarkdownBundle, setIsPackagingMarkdownBundle] =
|
||||
useState(false);
|
||||
|
||||
const artifactOptions = useMemo(() => {
|
||||
return (artifacts ?? []).map((artifactPath) => ({
|
||||
|
|
@ -145,19 +151,114 @@ export function ArtifactFileDetail({
|
|||
},
|
||||
});
|
||||
|
||||
const resolveMarkdownAssetUrlForDownload = useCallback(
|
||||
(rawPath: string): string | null => {
|
||||
const normalizedRef = normalizeReference(rawPath);
|
||||
if (!normalizedRef) return null;
|
||||
if (isExternalReference(normalizedRef)) return normalizedRef;
|
||||
|
||||
if (normalizedRef.startsWith("/mnt/user-data/")) {
|
||||
return urlOfArtifact({ filepath: normalizedRef, threadId });
|
||||
}
|
||||
if (normalizedRef.startsWith("mnt/user-data/")) {
|
||||
return urlOfArtifact({ filepath: `/${normalizedRef}`, threadId });
|
||||
}
|
||||
|
||||
const resolvedVirtualPath = resolveReferencedVirtualPath(
|
||||
normalizedRef,
|
||||
filepath,
|
||||
);
|
||||
if (!resolvedVirtualPath) return null;
|
||||
return urlOfArtifact({ filepath: resolvedVirtualPath, threadId });
|
||||
},
|
||||
[filepath, threadId],
|
||||
);
|
||||
|
||||
// 下载为 DOCX
|
||||
const handleDownloadDocx = useCallback(() => {
|
||||
if (content) {
|
||||
void downloadAsDocx(content, fileName);
|
||||
void downloadAsDocx(content, fileName, {
|
||||
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
|
||||
});
|
||||
}
|
||||
}, [content, fileName, downloadAsDocx]);
|
||||
}, [content, fileName, downloadAsDocx, resolveMarkdownAssetUrlForDownload]);
|
||||
|
||||
// 下载为 PDF
|
||||
const handleDownloadPdf = useCallback(() => {
|
||||
if (content) {
|
||||
void downloadAsPdf(content, fileName);
|
||||
void downloadAsPdf(content, fileName, {
|
||||
resolveAssetUrl: resolveMarkdownAssetUrlForDownload,
|
||||
});
|
||||
}
|
||||
}, [content, fileName, downloadAsPdf]);
|
||||
}, [content, fileName, downloadAsPdf, resolveMarkdownAssetUrlForDownload]);
|
||||
|
||||
const handleDownloadMarkdownBundle = useCallback(async () => {
|
||||
if (!threadId || !content) return;
|
||||
setIsPackagingMarkdownBundle(true);
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const markdownEntryPath = toWorkspaceRelativePath(filepath) ?? fileName;
|
||||
const referencedTargets = collectMarkdownAssetTargets(content);
|
||||
const refToVirtualPath = new Map<string, string>();
|
||||
|
||||
for (const ref of referencedTargets) {
|
||||
const resolved = resolveReferencedVirtualPath(ref, filepath);
|
||||
if (resolved) {
|
||||
refToVirtualPath.set(ref, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const refToRelativeZipPath = new Map<string, string>();
|
||||
const addedVirtualPaths = new Set<string>();
|
||||
|
||||
for (const [ref, virtualPath] of refToVirtualPath) {
|
||||
const artifactEntryPath = toWorkspaceRelativePath(virtualPath);
|
||||
if (!artifactEntryPath) continue;
|
||||
|
||||
const relativeFromMarkdown = toRelativePath(
|
||||
dirnamePosix(markdownEntryPath),
|
||||
artifactEntryPath,
|
||||
);
|
||||
refToRelativeZipPath.set(ref, relativeFromMarkdown || getFileName(artifactEntryPath));
|
||||
|
||||
if (addedVirtualPaths.has(virtualPath)) continue;
|
||||
addedVirtualPaths.add(virtualPath);
|
||||
|
||||
const response = await fetch(
|
||||
urlOfArtifact({
|
||||
filepath: virtualPath,
|
||||
threadId,
|
||||
}),
|
||||
);
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
const data = await response.arrayBuffer();
|
||||
zip.file(artifactEntryPath, data);
|
||||
}
|
||||
|
||||
const rewrittenMarkdown = rewriteMarkdownLinksForBundle(
|
||||
content,
|
||||
refToRelativeZipPath,
|
||||
);
|
||||
zip.file(markdownEntryPath, rewrittenMarkdown);
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const zipName = `${fileName.replace(/\.md$/i, "") || "document"}.zip`;
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = zipName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch (error) {
|
||||
console.error("Failed to package markdown bundle:", error);
|
||||
toast.error("Failed to package markdown with referenced files.");
|
||||
} finally {
|
||||
setIsPackagingMarkdownBundle(false);
|
||||
}
|
||||
}, [threadId, content, filepath, fileName, t.common.exportSuccess]);
|
||||
|
||||
// 全屏切换处理
|
||||
const handleFullscreenToggle = useCallback(() => {
|
||||
|
|
@ -185,8 +286,8 @@ export function ArtifactFileDetail({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
<ArtifactHeader className="">
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<ArtifactHeader className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-3 flex min-w-0 items-center justify-start gap-2 overflow-hidden">
|
||||
{previewable && (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
|
|
@ -245,10 +346,12 @@ export function ArtifactFileDetail({
|
|||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
{/* 放大缩小选择器 */}
|
||||
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
||||
{/* 仅在代码视图显示缩放控制 */}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 grow items-center justify-center">
|
||||
<div className="col-span-6 flex min-w-0 items-center justify-center px-1">
|
||||
<ArtifactTitle>
|
||||
{isWriteFile ? (
|
||||
<div className="w-full overflow-hidden px-2 text-center text-ellipsis whitespace-nowrap">
|
||||
|
|
@ -263,7 +366,7 @@ export function ArtifactFileDetail({
|
|||
)}
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
<div className="flex items-center justify-end overflow-hidden">
|
||||
<div className="col-span-3 flex min-w-0 items-center justify-end overflow-hidden">
|
||||
<ArtifactActions>
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
|
|
@ -337,20 +440,35 @@ export function ArtifactFileDetail({
|
|||
</ArtifactAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={urlOfArtifact({
|
||||
filepath,
|
||||
threadId: threadId ?? "",
|
||||
download: true,
|
||||
})}
|
||||
target="_blank"
|
||||
className="w-full cursor-pointer"
|
||||
{language === "markdown" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadMarkdownBundle}
|
||||
disabled={isPackagingMarkdownBundle}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{isPackagingMarkdownBundle ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
{t.common.downloadOriginal}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</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 文件显示。 */}
|
||||
{canConvertToDocxPdf && (
|
||||
<>
|
||||
|
|
@ -475,6 +593,7 @@ export function ArtifactFileDetail({
|
|||
language={language ?? "text"}
|
||||
zoom={zoom}
|
||||
threadId={threadId}
|
||||
filepath={filepath}
|
||||
/>
|
||||
|
||||
)}
|
||||
|
|
@ -489,40 +608,174 @@ export function ArtifactFileDetail({
|
|||
</div>
|
||||
)}
|
||||
{!isCodeFile && (
|
||||
<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}`}
|
||||
/>
|
||||
isOfficePreviewKind(artifactPreviewKind) ? (
|
||||
<ArtifactOfficePreview
|
||||
className="h-full mb-[207px]"
|
||||
kind={artifactPreviewKind}
|
||||
artifactUrl={artifactUrl}
|
||||
fileName={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>
|
||||
</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({
|
||||
content,
|
||||
language,
|
||||
zoom = 100,
|
||||
threadId,
|
||||
filepath,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
zoom?: number;
|
||||
threadId: string;
|
||||
filepath?: string;
|
||||
}) {
|
||||
const zoomScale = zoom / 100;
|
||||
const normalizedContent = useMemo(() => {
|
||||
return rewriteArtifactImagePaths(content ?? "", threadId);
|
||||
}, [content, threadId]);
|
||||
return rewriteArtifactImagePaths(content ?? "", threadId, filepath);
|
||||
}, [content, threadId, filepath]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full bg-white mb-[207px] p-[20px]")}
|
||||
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
|
||||
style={{ "--zoom-scale": zoomScale } as CSSProperties}
|
||||
>
|
||||
<Streamdown
|
||||
className="w-full"
|
||||
|
|
@ -588,8 +841,276 @@ function PreviewIframe({
|
|||
);
|
||||
}
|
||||
|
||||
function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
||||
if (!threadId || !/\/?mnt\/user-data\//.test(content)) {
|
||||
function ArtifactOfficePreview({
|
||||
className,
|
||||
kind,
|
||||
artifactUrl,
|
||||
fileName,
|
||||
}: {
|
||||
className?: string;
|
||||
kind: ArtifactPreviewKind;
|
||||
artifactUrl: string;
|
||||
fileName: string;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [xlsxHtml, setXlsxHtml] = useState<string>("");
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [activeSheet, setActiveSheet] = useState<string>("");
|
||||
const docxContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pptxContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const workbookRef = useRef<XLSX.WorkBook | null>(null);
|
||||
|
||||
const canRenderDocx = kind === "docx";
|
||||
const canRenderXlsx = kind === "xlsx";
|
||||
const canRenderPptx = kind === "pptx";
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function renderDocx() {
|
||||
if (!canRenderDocx || !artifactUrl || !docxContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(artifactUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
if (disposed || !docxContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
docxContainerRef.current.innerHTML = "";
|
||||
await renderAsync(blob, docxContainerRef.current, undefined, {
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
inWrapper: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to render docx preview:", err);
|
||||
if (!disposed) {
|
||||
setError("无法预览该 DOCX 文件。");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderDocx();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [artifactUrl, canRenderDocx]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function parseXlsx() {
|
||||
if (!canRenderXlsx || !artifactUrl) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
workbookRef.current = null;
|
||||
try {
|
||||
const response = await fetch(artifactUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
const workbook = XLSX.read(bytes, { type: "array" });
|
||||
workbookRef.current = workbook;
|
||||
const names = workbook.SheetNames ?? [];
|
||||
if (names.length === 0) {
|
||||
throw new Error("Empty workbook");
|
||||
}
|
||||
if (disposed) return;
|
||||
setSheetNames(names);
|
||||
const first = names[0] ?? "";
|
||||
setActiveSheet(first);
|
||||
const sheet = workbook.Sheets[first];
|
||||
const html = sheet
|
||||
? XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" })
|
||||
: "";
|
||||
setXlsxHtml(html);
|
||||
} catch (err) {
|
||||
console.error("Failed to render xlsx preview:", err);
|
||||
if (!disposed) {
|
||||
setError("无法预览该 Excel 文件。");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void parseXlsx();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [artifactUrl, canRenderXlsx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRenderXlsx || !activeSheet || !workbookRef.current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sheet = workbookRef.current.Sheets[activeSheet];
|
||||
if (!sheet) return;
|
||||
setXlsxHtml(XLSX.utils.sheet_to_html(sheet, { id: "artifact-xlsx-preview" }));
|
||||
} catch (err) {
|
||||
console.error("Failed to switch xlsx sheet:", err);
|
||||
setError("切换工作表失败。");
|
||||
}
|
||||
}, [activeSheet, canRenderXlsx]);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
type PptxPreviewModule = {
|
||||
init: (
|
||||
container: HTMLElement,
|
||||
options: { width: number; height: number },
|
||||
) => {
|
||||
preview: (buffer: ArrayBuffer) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
|
||||
async function renderPptx() {
|
||||
if (!canRenderPptx || !artifactUrl || !pptxContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(artifactUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
const pptxModule = (await import("pptx-preview")) as unknown as PptxPreviewModule;
|
||||
if (disposed || !pptxContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const container = pptxContainerRef.current;
|
||||
container.innerHTML = "";
|
||||
const previewer = pptxModule.init(container, { width: 960, height: 540 });
|
||||
await Promise.resolve(previewer.preview(bytes));
|
||||
} catch (err) {
|
||||
console.error("Failed to render pptx preview:", err);
|
||||
if (!disposed) {
|
||||
setError("无法预览该 PPT 文件。");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderPptx();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [artifactUrl, canRenderPptx]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
|
||||
{canRenderXlsx && sheetNames.length > 0 && (
|
||||
<div className="border-border flex items-center gap-1 overflow-x-auto border-b p-2">
|
||||
{sheetNames.map((sheetName) => (
|
||||
<button
|
||||
key={sheetName}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs whitespace-nowrap",
|
||||
activeSheet === sheetName
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setActiveSheet(sheetName)}
|
||||
>
|
||||
{sheetName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-auto p-4">
|
||||
{canRenderDocx && (
|
||||
<div
|
||||
ref={docxContainerRef}
|
||||
className="docx-preview-wrap mx-auto max-w-5xl"
|
||||
/>
|
||||
)}
|
||||
{canRenderXlsx && xlsxHtml && (
|
||||
<div
|
||||
className="artifact-xlsx-preview overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
|
||||
/>
|
||||
)}
|
||||
{canRenderPptx && (
|
||||
<div
|
||||
ref={pptxContainerRef}
|
||||
className="pptx-preview-wrap mx-auto w-full overflow-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<ArtifactPreviewFallback
|
||||
fileName={fileName}
|
||||
artifactUrl={artifactUrl}
|
||||
message={error}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/85">
|
||||
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactPreviewFallback({
|
||||
message,
|
||||
fileName,
|
||||
artifactUrl,
|
||||
}: {
|
||||
message: string;
|
||||
fileName: string;
|
||||
artifactUrl: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 grid place-content-center bg-white p-6 text-center">
|
||||
<p className="text-foreground mb-2 text-sm font-medium">{fileName}</p>
|
||||
<p className="text-muted-foreground mb-3 text-xs">{message}</p>
|
||||
<a
|
||||
className="text-primary text-sm font-medium underline"
|
||||
href={artifactUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
在新标签页打开
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rewriteArtifactImagePaths(
|
||||
content: string,
|
||||
threadId?: string,
|
||||
baseFilepath?: string,
|
||||
) {
|
||||
if (!threadId) {
|
||||
return content;
|
||||
}
|
||||
|
||||
|
|
@ -597,6 +1118,20 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
|||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
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(
|
||||
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
|
||||
|
|
@ -604,8 +1139,18 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
|||
return `})`;
|
||||
},
|
||||
);
|
||||
const markdownRelativeRewritten = markdownRewritten.replace(
|
||||
/!\[([^\]]*)\]\(\s*([^) \t]+)\s*\)/g,
|
||||
(_full, alt, rawPath) => {
|
||||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||||
if (!absoluteUrl) {
|
||||
return ``;
|
||||
}
|
||||
return ``;
|
||||
},
|
||||
);
|
||||
|
||||
const shorthandMarkdownRewritten = markdownRewritten.replace(
|
||||
const shorthandMarkdownRewritten = markdownRelativeRewritten.replace(
|
||||
/!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/outputs\/[^)\s)]+)\s*[))]/g,
|
||||
(_full, alt, rawPath) => {
|
||||
return `})`;
|
||||
|
|
@ -613,9 +1158,16 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
|||
);
|
||||
|
||||
return shorthandMarkdownRewritten.replace(
|
||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi,
|
||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
|
||||
(_full, prefix, quote, rawPath) => {
|
||||
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
|
||||
if (/^\/?mnt\/user-data\/outputs\//.test(rawPath)) {
|
||||
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
|
||||
}
|
||||
const absoluteUrl = toArtifactUrlFromRelative(rawPath);
|
||||
if (absoluteUrl) {
|
||||
return `${prefix}${quote}${absoluteUrl}${quote}`;
|
||||
}
|
||||
return `${prefix}${quote}${rawPath}${quote}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -626,6 +1178,9 @@ type ArtifactPreviewKind =
|
|||
| "video"
|
||||
| "audio"
|
||||
| "pdf"
|
||||
| "docx"
|
||||
| "xlsx"
|
||||
| "pptx"
|
||||
| "other";
|
||||
|
||||
function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
|
||||
|
|
@ -636,9 +1191,22 @@ function getArtifactPreviewKind(filepath: string): ArtifactPreviewKind {
|
|||
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(lower)) return "video";
|
||||
if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) return "audio";
|
||||
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";
|
||||
}
|
||||
|
||||
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 {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
|
|
|
|||
|
|
@ -37,11 +37,12 @@ interface ArtifactsProviderProps {
|
|||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifacts] = useState<string[]>([]);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpen] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const [autoSelect, setAutoSelect] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
// const [open, setOpen] = useState(
|
||||
// env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
// );
|
||||
const [autoOpen, setAutoOpen] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useState, type ComponentProps } from "react";
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
|
|
@ -14,10 +15,14 @@ export function CopyButton({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(clipboardData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await copyToClipboard(clipboardData);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// no-op: caller controls error UI if needed
|
||||
}
|
||||
}, [clipboardData]);
|
||||
return (
|
||||
<Tooltip content={t.clipboard.copyToClipboard}>
|
||||
|
|
|
|||
|
|
@ -342,7 +342,6 @@ export function InputBox({
|
|||
"size-full",
|
||||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||||
)}
|
||||
submitOnEnter={false}
|
||||
disabled={isInputDisabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
|
|
@ -39,6 +40,8 @@ import { Tooltip } from "../tooltip";
|
|||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
const TOOL_CONTENT_COLLAPSE_THRESHOLD = 320;
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
|
|
@ -76,6 +79,9 @@ export function MessageGroup({
|
|||
return filteredSteps[filteredSteps.length - 1];
|
||||
}
|
||||
}, [lastToolCallStep, steps]);
|
||||
const totalToolStepCount = aboveLastToolCallSteps.length + (lastToolCallStep ? 1 : 0);
|
||||
const shouldShowToolSteps = !!lastToolCallStep &&
|
||||
(showAbove || aboveLastToolCallSteps.length === 0);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
return (
|
||||
<ChainOfThought
|
||||
|
|
@ -87,14 +93,17 @@ export function MessageGroup({
|
|||
key="above"
|
||||
className="w-full items-start justify-start text-left"
|
||||
variant="ghost"
|
||||
onClick={() => setShowAbove(!showAbove)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setShowAbove((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<span className="opacity-60">
|
||||
{showAbove
|
||||
? t.toolCalls.lessSteps
|
||||
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
||||
: t.toolCalls.moreSteps(totalToolStepCount)}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
|
|
@ -108,7 +117,7 @@ export function MessageGroup({
|
|||
></ChainOfThoughtStep>
|
||||
</Button>
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
{shouldShowToolSteps && (
|
||||
<ChainOfThoughtContent className="px-4 pb-2">
|
||||
{showAbove &&
|
||||
aboveLastToolCallSteps.map((step) =>
|
||||
|
|
@ -145,7 +154,10 @@ export function MessageGroup({
|
|||
key={lastReasoningStep.id}
|
||||
className="w-full items-start justify-start text-left"
|
||||
variant="ghost"
|
||||
onClick={() => setShowLastThinking(!showLastThinking)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setShowLastThinking((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<ChainOfThoughtStep
|
||||
|
|
@ -203,6 +215,33 @@ function ToolCall({
|
|||
const { t } = useI18n();
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
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") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
|
|
@ -377,19 +416,31 @@ function ToolCall({
|
|||
return t.toolCalls.executeCommand;
|
||||
}
|
||||
const command: string | undefined = (args as { command: string })?.command;
|
||||
const shouldCollapse = !!command && command.length > TOOL_CONTENT_COLLAPSE_THRESHOLD;
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={description}
|
||||
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 && (
|
||||
<CodeBlock
|
||||
className="mx-0 cursor-pointer border-none px-0"
|
||||
showLineNumbers={false}
|
||||
language="bash"
|
||||
code={command}
|
||||
/>
|
||||
<ExpandableToolContent content={command} expanded={isCommandExpanded} />
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
|
||||
export function RecentChatList() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -119,7 +120,7 @@ export function RecentChatList() {
|
|||
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
|
||||
const shareUrl = `${baseUrl}/workspace/chats/${threadId}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
await copyToClipboard(shareUrl);
|
||||
toast.success(t.clipboard.linkCopied);
|
||||
} catch {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
|
|
@ -178,7 +179,7 @@ export function RecentChatList() {
|
|||
<div>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread.thread_id)}
|
||||
href={`${pathOfThread(thread.thread_id)}?is_chatting=true`}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -277,6 +277,8 @@ export const enUS: Translations = {
|
|||
writeFile: "Write file",
|
||||
clickToViewContent: "Click to view file content",
|
||||
writeTodos: "Update to-do list",
|
||||
expandContent: "Expand",
|
||||
collapseContent: "Collapse",
|
||||
skillInstallTooltip: "Install skill and make it available to DeerFlow",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ export interface Translations {
|
|||
writeFile: string;
|
||||
clickToViewContent: string;
|
||||
writeTodos: string;
|
||||
expandContent: string;
|
||||
collapseContent: string;
|
||||
skillInstallTooltip: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -265,6 +265,8 @@ export const zhCN: Translations = {
|
|||
writeFile: "写入文件",
|
||||
clickToViewContent: "点击查看文件内容",
|
||||
writeTodos: "更新 To-do 列表",
|
||||
expandContent: "展开",
|
||||
collapseContent: "收起",
|
||||
skillInstallTooltip: "安装技能并使其可在 DeerFlow 中使用",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -46,27 +46,74 @@ export type LegacyThreadStreamOptions = {
|
|||
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 {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
const directMessage = readMessageCandidate(error);
|
||||
if (directMessage) {
|
||||
return directMessage;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const message = Reflect.get(error, "message");
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
|
||||
const visited = new Set<object>();
|
||||
const queue: unknown[] = [error];
|
||||
const preferredKeys = ["message", "detail", "error"];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = readMessageCandidate(current);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
const nestedError = Reflect.get(error, "error");
|
||||
if (nestedError instanceof Error && nestedError.message.trim()) {
|
||||
return nestedError.message;
|
||||
|
||||
if (typeof current !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (typeof nestedError === "string" && nestedError.trim()) {
|
||||
return nestedError;
|
||||
|
||||
if (visited.has(current)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(current);
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
const candidate = Reflect.get(current, key);
|
||||
const parsed = readMessageCandidate(candidate);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
if (candidate && typeof candidate === "object") {
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -151,6 +198,9 @@ export function useThreadStream({
|
|||
// and to allow access to the current thread id in onUpdateEvent
|
||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||
const startedRef = useRef(false);
|
||||
const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
|
|
@ -182,6 +232,23 @@ export function useThreadStream({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const showStreamErrorToast = useCallback((error: unknown) => {
|
||||
const message = getStreamErrorMessage(error);
|
||||
const now = Date.now();
|
||||
const lastToast = lastErrorToastRef.current;
|
||||
if (
|
||||
lastToast &&
|
||||
lastToast.message === message &&
|
||||
now - lastToast.timestamp < STREAM_ERROR_TOAST_DEDUPE_WINDOW_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastErrorToastRef.current = { message, timestamp: now };
|
||||
console.error("[useThreadStream] conversation stream error:", error);
|
||||
console.error("[useThreadStream] parsed error message:", message);
|
||||
toast.error(STREAM_ERROR_TOAST_MESSAGE);
|
||||
}, []);
|
||||
|
||||
const handleStreamStart = useCallback(
|
||||
(_threadId: string) => {
|
||||
threadIdRef.current = _threadId;
|
||||
|
|
@ -261,7 +328,7 @@ export function useThreadStream({
|
|||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
showStreamErrorToast(error);
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
|
|
@ -286,6 +353,13 @@ export function useThreadStream({
|
|||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread.error) {
|
||||
return;
|
||||
}
|
||||
showStreamErrorToast(thread.error);
|
||||
}, [thread.error, showStreamErrorToast]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string | undefined,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
ImageRun,
|
||||
type ParagraphChild,
|
||||
} from "docx";
|
||||
import { marked } from "marked";
|
||||
|
||||
|
|
@ -57,6 +59,10 @@ export interface DocxOptions {
|
|||
* @default 22 (11pt)
|
||||
*/
|
||||
codeFontSize?: number;
|
||||
/**
|
||||
* 解析 Markdown 里的资源路径(如图片相对路径)
|
||||
*/
|
||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -80,10 +86,18 @@ export async function downloadMarkdownAsDocx(
|
|||
filename: string,
|
||||
options: DocxOptions = {},
|
||||
): Promise<void> {
|
||||
const { codeFont = "Courier New", codeFontSize = 22 } = options;
|
||||
const {
|
||||
codeFont = "Courier New",
|
||||
codeFontSize = 22,
|
||||
resolveAssetUrl,
|
||||
} = options;
|
||||
|
||||
const tokens = marked.lexer(markdown);
|
||||
const children = parseTokensToDocx(tokens, { codeFont, codeFontSize });
|
||||
const children = await parseTokensToDocx(tokens, {
|
||||
codeFont,
|
||||
codeFontSize,
|
||||
resolveAssetUrl,
|
||||
});
|
||||
|
||||
const doc = new DocxDocument({
|
||||
sections: [{ children }],
|
||||
|
|
@ -112,7 +126,9 @@ export async function downloadMarkdownAsDocx(
|
|||
export async function downloadMarkdownAsPdf(
|
||||
markdown: string,
|
||||
filename: string,
|
||||
options: PdfOptions = {},
|
||||
options: PdfOptions & {
|
||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const html2pdf = await loadHtml2Pdf();
|
||||
|
||||
|
|
@ -121,10 +137,16 @@ export async function downloadMarkdownAsPdf(
|
|||
format = "a4",
|
||||
orientation = "portrait",
|
||||
scale = 2,
|
||||
resolveAssetUrl,
|
||||
} = options;
|
||||
|
||||
const normalizedMarkdown = await rewriteMarkdownImageSources(
|
||||
markdown,
|
||||
resolveAssetUrl,
|
||||
);
|
||||
|
||||
// 解析 Markdown 为 HTML
|
||||
const htmlContent = await marked.parse(markdown);
|
||||
const htmlContent = await marked.parse(normalizedMarkdown);
|
||||
|
||||
// 创建容器并应用样式
|
||||
const container = createStyledContainer(htmlContent);
|
||||
|
|
@ -309,16 +331,17 @@ function fixColorsForHtml2Canvas(clonedDoc: Document): void {
|
|||
/**
|
||||
* 解析 Markdown Token 为 DOCX Paragraph
|
||||
*/
|
||||
function parseTokensToDocx(
|
||||
async function parseTokensToDocx(
|
||||
tokens: MarkdownToken[],
|
||||
options: Required<DocxOptions>,
|
||||
): Paragraph[] {
|
||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
||||
Pick<DocxOptions, "resolveAssetUrl">,
|
||||
): Promise<Paragraph[]> {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case "heading": {
|
||||
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||
const runs = await parseInlineTokens(token.tokens ?? [], options);
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: runs,
|
||||
|
|
@ -330,7 +353,7 @@ function parseTokensToDocx(
|
|||
}
|
||||
|
||||
case "paragraph": {
|
||||
const runs = parseInlineTokens(token.tokens ?? [], options);
|
||||
const runs = await parseInlineTokens(token.tokens ?? [], options);
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: runs.length > 0 ? runs : [new TextRun("")],
|
||||
|
|
@ -361,8 +384,8 @@ function parseTokensToDocx(
|
|||
}
|
||||
|
||||
case "list": {
|
||||
token.items?.forEach((item: MarkdownToken) => {
|
||||
const runs = parseInlineTokens(
|
||||
for (const item of token.items ?? []) {
|
||||
const runs = await parseInlineTokens(
|
||||
item.tokens?.[0]?.tokens ?? [],
|
||||
options,
|
||||
);
|
||||
|
|
@ -373,12 +396,12 @@ function parseTokensToDocx(
|
|||
spacing: { after: 80 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "blockquote": {
|
||||
const runs = parseInlineTokens(
|
||||
const runs = await parseInlineTokens(
|
||||
token.tokens?.[0]?.tokens ?? [],
|
||||
options,
|
||||
);
|
||||
|
|
@ -407,6 +430,19 @@ function parseTokensToDocx(
|
|||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const imageRun = await createImageRunFromToken(token, options);
|
||||
if (imageRun) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [imageRun],
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -416,11 +452,12 @@ function parseTokensToDocx(
|
|||
/**
|
||||
* 解析行内 Token 为 TextRun
|
||||
*/
|
||||
function parseInlineTokens(
|
||||
async function parseInlineTokens(
|
||||
tokens: MarkdownToken[],
|
||||
options: Required<DocxOptions>,
|
||||
): TextRun[] {
|
||||
const runs: TextRun[] = [];
|
||||
options: Required<Pick<DocxOptions, "codeFont" | "codeFontSize">> &
|
||||
Pick<DocxOptions, "resolveAssetUrl">,
|
||||
): Promise<ParagraphChild[]> {
|
||||
const runs: ParagraphChild[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
|
|
@ -460,6 +497,14 @@ function parseInlineTokens(
|
|||
runs.push(new TextRun({ text: "", break: 1 }));
|
||||
break;
|
||||
|
||||
case "image": {
|
||||
const imageRun = await createImageRunFromToken(token, options);
|
||||
if (imageRun) {
|
||||
runs.push(imageRun);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
runs.push(new TextRun(token.raw ?? ""));
|
||||
}
|
||||
|
|
@ -468,6 +513,155 @@ function parseInlineTokens(
|
|||
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,6 +1,11 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
import { downloadMarkdownAsDocx, downloadMarkdownAsPdf } from "./converter";
|
||||
import {
|
||||
downloadMarkdownAsDocx,
|
||||
downloadMarkdownAsPdf,
|
||||
type DocxOptions,
|
||||
type PdfOptions,
|
||||
} from "./converter";
|
||||
|
||||
/**
|
||||
* Markdown 下载 Hook 配置选项
|
||||
|
|
@ -31,11 +36,21 @@ export interface UseMarkdownDownloadReturn {
|
|||
/**
|
||||
* 下载为 DOCX
|
||||
*/
|
||||
downloadAsDocx: (markdown: string, filename: string) => Promise<void>;
|
||||
downloadAsDocx: (
|
||||
markdown: string,
|
||||
filename: string,
|
||||
options?: DocxOptions,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* 下载为 PDF
|
||||
*/
|
||||
downloadAsPdf: (markdown: string, filename: string) => Promise<void>;
|
||||
downloadAsPdf: (
|
||||
markdown: string,
|
||||
filename: string,
|
||||
options?: PdfOptions & {
|
||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
||||
},
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* 是否可以下载(没有正在进行的下载)
|
||||
*/
|
||||
|
|
@ -82,14 +97,14 @@ export function useMarkdownDownload(
|
|||
);
|
||||
|
||||
const downloadAsDocx = useCallback(
|
||||
async (markdown: string, filename: string) => {
|
||||
async (markdown: string, filename: string, options?: DocxOptions) => {
|
||||
if (isDownloading) return;
|
||||
|
||||
setIsDownloading("docx");
|
||||
onDownloadStart?.("docx");
|
||||
|
||||
try {
|
||||
await downloadMarkdownAsDocx(markdown, filename);
|
||||
await downloadMarkdownAsDocx(markdown, filename, options);
|
||||
} catch (error) {
|
||||
onError?.(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
|
|
@ -104,14 +119,20 @@ export function useMarkdownDownload(
|
|||
);
|
||||
|
||||
const downloadAsPdf = useCallback(
|
||||
async (markdown: string, filename: string) => {
|
||||
async (
|
||||
markdown: string,
|
||||
filename: string,
|
||||
options?: PdfOptions & {
|
||||
resolveAssetUrl?: (rawPath: string) => string | null | Promise<string | null>;
|
||||
},
|
||||
) => {
|
||||
if (isDownloading) return;
|
||||
|
||||
setIsDownloading("pdf");
|
||||
onDownloadStart?.("pdf");
|
||||
|
||||
try {
|
||||
await downloadMarkdownAsPdf(markdown, filename);
|
||||
await downloadMarkdownAsPdf(markdown, filename, options);
|
||||
} catch (error) {
|
||||
onError?.(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
|
|
|
|||
|
|
@ -491,3 +491,11 @@ pre {
|
|||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.pptx-preview-wrap {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pptx-preview-wrap .pptx-preview-wrapper {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
PRIMARY_THREAD_ID,
|
||||
THREAD_WITH_ARTIFACTS,
|
||||
THREAD_WITH_HTML_ARTIFACT,
|
||||
THREAD_WITH_IMAGE_ARTIFACT,
|
||||
openChat,
|
||||
reuseThreadChatEntry,
|
||||
sendMessage,
|
||||
skipIfMissingThread,
|
||||
waitForAnyMessages,
|
||||
waitForMessageListReady,
|
||||
|
|
@ -87,8 +89,22 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
|||
(await htmlFile.count()) === 0,
|
||||
"当前线程没有 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();
|
||||
|
||||
const htmlArtifactResponse = await htmlArtifactResponsePromise;
|
||||
expect(htmlArtifactResponse.headers()["content-disposition"] ?? "").toContain(
|
||||
"attachment;",
|
||||
);
|
||||
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
@ -117,4 +133,32 @@ test.describe("聊天工作台 / Artifact 面板", () => {
|
|||
await expect(page.getByTestId("artifacts-open-button")).toBeVisible();
|
||||
await expect(page.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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
PRIMARY_THREAD_ID,
|
||||
openChat,
|
||||
reuseThreadChatEntry,
|
||||
skipIfMissingThread,
|
||||
waitForMessageListReady,
|
||||
} from "./support/chat-helpers";
|
||||
|
||||
test.use({
|
||||
video: "on",
|
||||
});
|
||||
|
||||
test.describe("聊天工作台 / 错误提示", () => {
|
||||
test("DF-ERR-001 对话流失败时显示错误 toast", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
|
||||
|
||||
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
|
||||
await waitForMessageListReady(page, { requireMessages: false });
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
|
||||
await route.abort("failed");
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
const submit = page.locator("button[aria-label='Submit']");
|
||||
await textarea.fill("触发错误 toast 测试");
|
||||
await submit.click({ force: true });
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "出现了某些错误。" })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("DF-ERR-002 相同错误短时间不重复弹 toast", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipIfMissingThread(testInfo, PRIMARY_THREAD_ID, "FRONTEND_E2E_THREAD_ID");
|
||||
|
||||
await openChat(page, reuseThreadChatEntry(PRIMARY_THREAD_ID!));
|
||||
await waitForMessageListReady(page, { requireMessages: false });
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST" && /\/runs\b/.test(request.url())) {
|
||||
await route.abort("failed");
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const textarea = page.locator("textarea[name='message']");
|
||||
const submit = page.locator("button[aria-label='Submit']");
|
||||
const errorToasts = page.locator('[data-sonner-toast][data-type="error"]');
|
||||
|
||||
await textarea.fill("触发重复错误 toast 测试 1");
|
||||
await submit.click({ force: true });
|
||||
|
||||
await expect(errorToasts.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(errorToasts).toHaveCount(1);
|
||||
|
||||
// 在去重窗口(2s)内再次触发同类错误,不应新增 toast
|
||||
await textarea.fill("触发重复错误 toast 测试 2");
|
||||
await submit.click({ force: true });
|
||||
|
||||
await expect(errorToasts).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import {
|
||||
THREAD_FOR_WELCOME,
|
||||
newChatEntry,
|
||||
openChat,
|
||||
reuseThreadChatEntry,
|
||||
sendMessage,
|
||||
skipIfMissingThread,
|
||||
waitForAnyMessages,
|
||||
waitForMessageListReady,
|
||||
} from "./support/chat-helpers";
|
||||
|
||||
test.use({
|
||||
screenshot: "on",
|
||||
video: "on",
|
||||
});
|
||||
|
||||
test.describe("线程路由(无 isnew)", () => {
|
||||
test("/new 始终走欢迎态,发送后进入具体 thread 路由", async ({ page }, testInfo) => {
|
||||
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
|
||||
|
|
@ -29,4 +36,67 @@ test.describe("线程路由(无 isnew)", () => {
|
|||
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`));
|
||||
await expect(page.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