scrum-skills 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/.agents/skills/diagram-design/LICENSE +4 -0
  2. package/.agents/skills/diagram-design/SKILL.md +413 -0
  3. package/.agents/skills/diagram-design/UPSTREAM.json +76 -0
  4. package/.agents/skills/diagram-design/UPSTREAM.md +23 -0
  5. package/.agents/skills/diagram-design/assets/example-architecture-containment.html +205 -0
  6. package/.agents/skills/diagram-design/assets/example-architecture-delta.html +193 -0
  7. package/.agents/skills/diagram-design/assets/example-architecture.html +183 -0
  8. package/.agents/skills/diagram-design/assets/example-axonometric-plan-campus.html +108 -0
  9. package/.agents/skills/diagram-design/assets/example-axonometric-plan-coffee-shop.html +121 -0
  10. package/.agents/skills/diagram-design/assets/example-axonometric-plan-warehouse.html +127 -0
  11. package/.agents/skills/diagram-design/assets/example-axonometric-plan.html +114 -0
  12. package/.agents/skills/diagram-design/assets/example-bar.html +129 -0
  13. package/.agents/skills/diagram-design/assets/example-beeswarm.html +384 -0
  14. package/.agents/skills/diagram-design/assets/example-box-plot.html +127 -0
  15. package/.agents/skills/diagram-design/assets/example-bubble.html +147 -0
  16. package/.agents/skills/diagram-design/assets/example-bump.html +142 -0
  17. package/.agents/skills/diagram-design/assets/example-data-flow.html +143 -0
  18. package/.agents/skills/diagram-design/assets/example-datalake.html +269 -0
  19. package/.agents/skills/diagram-design/assets/example-db-schema.html +302 -0
  20. package/.agents/skills/diagram-design/assets/example-dependency.html +224 -0
  21. package/.agents/skills/diagram-design/assets/example-deployment.html +190 -0
  22. package/.agents/skills/diagram-design/assets/example-dp-integration.html +81 -0
  23. package/.agents/skills/diagram-design/assets/example-dp-security-matrix.html +88 -0
  24. package/.agents/skills/diagram-design/assets/example-er.html +202 -0
  25. package/.agents/skills/diagram-design/assets/example-exploded-ai-stack.html +85 -0
  26. package/.agents/skills/diagram-design/assets/example-exploded-keyboard.html +84 -0
  27. package/.agents/skills/diagram-design/assets/example-exploded-phone.html +83 -0
  28. package/.agents/skills/diagram-design/assets/example-exploded-unboxing.html +84 -0
  29. package/.agents/skills/diagram-design/assets/example-exploded.html +83 -0
  30. package/.agents/skills/diagram-design/assets/example-fishbone.html +152 -0
  31. package/.agents/skills/diagram-design/assets/example-flowchart.html +163 -0
  32. package/.agents/skills/diagram-design/assets/example-gantt.html +137 -0
  33. package/.agents/skills/diagram-design/assets/example-heatmap.html +224 -0
  34. package/.agents/skills/diagram-design/assets/example-high-level-vertical.html +325 -0
  35. package/.agents/skills/diagram-design/assets/example-high-level.html +301 -0
  36. package/.agents/skills/diagram-design/assets/example-import-drawio.html +185 -0
  37. package/.agents/skills/diagram-design/assets/example-import-excalidraw.html +70 -0
  38. package/.agents/skills/diagram-design/assets/example-import-mermaid.html +70 -0
  39. package/.agents/skills/diagram-design/assets/example-it-state.html +128 -0
  40. package/.agents/skills/diagram-design/assets/example-journey.html +148 -0
  41. package/.agents/skills/diagram-design/assets/example-kanban.html +174 -0
  42. package/.agents/skills/diagram-design/assets/example-layers.html +123 -0
  43. package/.agents/skills/diagram-design/assets/example-line.html +111 -0
  44. package/.agents/skills/diagram-design/assets/example-loop.html +100 -0
  45. package/.agents/skills/diagram-design/assets/example-marimekko.html +148 -0
  46. package/.agents/skills/diagram-design/assets/example-medallion.html +159 -0
  47. package/.agents/skills/diagram-design/assets/example-nested.html +136 -0
  48. package/.agents/skills/diagram-design/assets/example-org-chart.html +45 -0
  49. package/.agents/skills/diagram-design/assets/example-polar.html +116 -0
  50. package/.agents/skills/diagram-design/assets/example-process.html +64 -0
  51. package/.agents/skills/diagram-design/assets/example-pyramid.html +119 -0
  52. package/.agents/skills/diagram-design/assets/example-quadrant-consultant.html +166 -0
  53. package/.agents/skills/diagram-design/assets/example-quadrant.html +135 -0
  54. package/.agents/skills/diagram-design/assets/example-radar.html +139 -0
  55. package/.agents/skills/diagram-design/assets/example-ridgeline.html +104 -0
  56. package/.agents/skills/diagram-design/assets/example-sankey.html +150 -0
  57. package/.agents/skills/diagram-design/assets/example-scatter.html +141 -0
  58. package/.agents/skills/diagram-design/assets/example-sequence-oauth.html +227 -0
  59. package/.agents/skills/diagram-design/assets/example-sequence.html +222 -0
  60. package/.agents/skills/diagram-design/assets/example-service-blueprint.html +185 -0
  61. package/.agents/skills/diagram-design/assets/example-slopegraph.html +119 -0
  62. package/.agents/skills/diagram-design/assets/example-state-lifecycle.html +92 -0
  63. package/.agents/skills/diagram-design/assets/example-state.html +147 -0
  64. package/.agents/skills/diagram-design/assets/example-story-map.html +184 -0
  65. package/.agents/skills/diagram-design/assets/example-streamgraph.html +99 -0
  66. package/.agents/skills/diagram-design/assets/example-swimlane.html +172 -0
  67. package/.agents/skills/diagram-design/assets/example-timeline.html +135 -0
  68. package/.agents/skills/diagram-design/assets/example-tree-block-decomposition.html +151 -0
  69. package/.agents/skills/diagram-design/assets/example-tree.html +173 -0
  70. package/.agents/skills/diagram-design/assets/example-treemap.html +107 -0
  71. package/.agents/skills/diagram-design/assets/example-uml-class.html +188 -0
  72. package/.agents/skills/diagram-design/assets/example-venn.html +110 -0
  73. package/.agents/skills/diagram-design/assets/example-wardley.html +153 -0
  74. package/.agents/skills/diagram-design/assets/example-waterfall.html +129 -0
  75. package/.agents/skills/diagram-design/assets/template-dark.html +99 -0
  76. package/.agents/skills/diagram-design/assets/template-full.html +421 -0
  77. package/.agents/skills/diagram-design/assets/template-motion.html +448 -0
  78. package/.agents/skills/diagram-design/assets/template-terminal.html +188 -0
  79. package/.agents/skills/diagram-design/assets/template.html +100 -0
  80. package/.agents/skills/diagram-design/references/animation.md +143 -0
  81. package/.agents/skills/diagram-design/references/export-registry.md +78 -0
  82. package/.agents/skills/diagram-design/references/export.md +376 -0
  83. package/.agents/skills/diagram-design/references/import-drawio.md +181 -0
  84. package/.agents/skills/diagram-design/references/import-excalidraw.md +130 -0
  85. package/.agents/skills/diagram-design/references/import-mermaid.md +137 -0
  86. package/.agents/skills/diagram-design/references/layout-budget.md +97 -0
  87. package/.agents/skills/diagram-design/references/onboarding.md +368 -0
  88. package/.agents/skills/diagram-design/references/output-spec.md +277 -0
  89. package/.agents/skills/diagram-design/references/primitive-annotation.md +36 -0
  90. package/.agents/skills/diagram-design/references/primitive-icons.md +827 -0
  91. package/.agents/skills/diagram-design/references/primitive-sketchy.md +43 -0
  92. package/.agents/skills/diagram-design/references/primitive-terminal.md +76 -0
  93. package/.agents/skills/diagram-design/references/primitives-core.md +146 -0
  94. package/.agents/skills/diagram-design/references/profiles.md +183 -0
  95. package/.agents/skills/diagram-design/references/semantic-patterns.md +154 -0
  96. package/.agents/skills/diagram-design/references/style-guide.md +236 -0
  97. package/.agents/skills/diagram-design/references/style-vivid.md +148 -0
  98. package/.agents/skills/diagram-design/references/type-architecture-delta.md +143 -0
  99. package/.agents/skills/diagram-design/references/type-architecture.md +117 -0
  100. package/.agents/skills/diagram-design/references/type-axonometric-plan.md +83 -0
  101. package/.agents/skills/diagram-design/references/type-bar.md +108 -0
  102. package/.agents/skills/diagram-design/references/type-data-flow.md +371 -0
  103. package/.agents/skills/diagram-design/references/type-db-schema.md +48 -0
  104. package/.agents/skills/diagram-design/references/type-dependency.md +44 -0
  105. package/.agents/skills/diagram-design/references/type-deployment.md +45 -0
  106. package/.agents/skills/diagram-design/references/type-dp-integration.md +407 -0
  107. package/.agents/skills/diagram-design/references/type-dp-security-matrix.md +379 -0
  108. package/.agents/skills/diagram-design/references/type-er.md +25 -0
  109. package/.agents/skills/diagram-design/references/type-exploded.md +115 -0
  110. package/.agents/skills/diagram-design/references/type-fishbone.md +75 -0
  111. package/.agents/skills/diagram-design/references/type-flowchart.md +23 -0
  112. package/.agents/skills/diagram-design/references/type-gantt.md +45 -0
  113. package/.agents/skills/diagram-design/references/type-heatmap.md +80 -0
  114. package/.agents/skills/diagram-design/references/type-high-level.md +470 -0
  115. package/.agents/skills/diagram-design/references/type-it-state.md +468 -0
  116. package/.agents/skills/diagram-design/references/type-journey.md +55 -0
  117. package/.agents/skills/diagram-design/references/type-kanban.md +49 -0
  118. package/.agents/skills/diagram-design/references/type-layers.md +26 -0
  119. package/.agents/skills/diagram-design/references/type-line.md +337 -0
  120. package/.agents/skills/diagram-design/references/type-loop.md +223 -0
  121. package/.agents/skills/diagram-design/references/type-medallion.md +356 -0
  122. package/.agents/skills/diagram-design/references/type-nested.md +22 -0
  123. package/.agents/skills/diagram-design/references/type-org-chart.md +44 -0
  124. package/.agents/skills/diagram-design/references/type-polar.md +136 -0
  125. package/.agents/skills/diagram-design/references/type-process.md +490 -0
  126. package/.agents/skills/diagram-design/references/type-pyramid.md +33 -0
  127. package/.agents/skills/diagram-design/references/type-quadrant.md +81 -0
  128. package/.agents/skills/diagram-design/references/type-radar.md +82 -0
  129. package/.agents/skills/diagram-design/references/type-sankey.md +75 -0
  130. package/.agents/skills/diagram-design/references/type-scatter.md +276 -0
  131. package/.agents/skills/diagram-design/references/type-sequence.md +130 -0
  132. package/.agents/skills/diagram-design/references/type-service-blueprint.md +103 -0
  133. package/.agents/skills/diagram-design/references/type-state.md +30 -0
  134. package/.agents/skills/diagram-design/references/type-story-map.md +46 -0
  135. package/.agents/skills/diagram-design/references/type-swimlane.md +22 -0
  136. package/.agents/skills/diagram-design/references/type-timeline.md +20 -0
  137. package/.agents/skills/diagram-design/references/type-tree.md +24 -0
  138. package/.agents/skills/diagram-design/references/type-treemap.md +137 -0
  139. package/.agents/skills/diagram-design/references/type-uml-class.md +65 -0
  140. package/.agents/skills/diagram-design/references/type-venn.md +26 -0
  141. package/.agents/skills/diagram-design/references/type-wardley.md +39 -0
  142. package/.agents/skills/diagram-design/references/type-waterfall.md +75 -0
  143. package/.agents/skills/scrum-meta/SKILL.md +36 -36
  144. package/.agents/skills/scrum-planning/SKILL.md +61 -61
  145. package/.agents/skills/scrum-planning/references/schema.md +384 -336
  146. package/.agents/skills/scrum-report/SKILL.md +42 -42
  147. package/.agents/skills/scrum-sprint/SKILL.md +49 -49
  148. package/.agents/skills/scrum-tdd/SKILL.md +140 -127
  149. package/README.md +224 -201
  150. package/assets/board/200.html +1 -1
  151. package/assets/board/404.html +1 -1
  152. package/assets/board/_nuxt/1XlYaCp9.js +1 -0
  153. package/assets/board/_nuxt/4gQF_HS3.js +1 -0
  154. package/assets/board/_nuxt/5okfNy5D.js +1 -0
  155. package/assets/board/_nuxt/{Bb3Limos.js → B0C2jsdk.js} +1 -1
  156. package/assets/board/_nuxt/B1j6UC4U.js +1 -0
  157. package/assets/board/_nuxt/B3LKy6o2.js +1 -0
  158. package/assets/board/_nuxt/BAQQngAq.js +13 -0
  159. package/assets/board/_nuxt/BE6t7Q2m.js +1 -0
  160. package/assets/board/_nuxt/BLvbupFS.js +1 -0
  161. package/assets/board/_nuxt/BQgzP7ur.js +1 -0
  162. package/assets/board/_nuxt/BRZIF_wo.js +1 -0
  163. package/assets/board/_nuxt/BW3xJdEx.js +1 -0
  164. package/assets/board/_nuxt/{Cm9lkUvP.js → BY36DFyj.js} +1 -1
  165. package/assets/board/_nuxt/BZlZD94i.js +1 -0
  166. package/assets/board/_nuxt/BZvXNwev.js +3 -0
  167. package/assets/board/_nuxt/BbxmvdIF.js +1 -0
  168. package/assets/board/_nuxt/BiWrMOsL.js +1 -0
  169. package/assets/board/_nuxt/BizDeOK5.js +1 -0
  170. package/assets/board/_nuxt/BkcaOYQ_.js +95 -0
  171. package/assets/board/_nuxt/BkoKCNUY.js +1 -0
  172. package/assets/board/_nuxt/BlKmZhhF.js +1 -0
  173. package/assets/board/_nuxt/BmBpmU73.js +1 -0
  174. package/assets/board/_nuxt/BrWE0up6.js +1 -0
  175. package/assets/board/_nuxt/Bs52qkgk.js +1 -0
  176. package/assets/board/_nuxt/BtzUbHO_.js +1 -0
  177. package/assets/board/_nuxt/BuBzMkFZ.js +1 -0
  178. package/assets/board/_nuxt/Bubk0xn-.js +1 -0
  179. package/assets/board/_nuxt/BxuBMtk_.js +3 -0
  180. package/assets/board/_nuxt/By1KXXLR.js +1 -0
  181. package/assets/board/_nuxt/BygIGL69.js +2 -0
  182. package/assets/board/_nuxt/C1iO5KH3.js +1 -0
  183. package/assets/board/_nuxt/C2YPmTzt.js +1 -0
  184. package/assets/board/_nuxt/C3SpAhCW.js +1 -0
  185. package/assets/board/_nuxt/C3ZRWz5L.js +1 -0
  186. package/assets/board/_nuxt/C4WdPP6K.js +1 -0
  187. package/assets/board/_nuxt/C6o03JHe.js +1 -0
  188. package/assets/board/_nuxt/C8qB5llI.js +1 -0
  189. package/assets/board/_nuxt/C9MgRdhu.js +1 -0
  190. package/assets/board/_nuxt/CCA81-4P.js +1 -0
  191. package/assets/board/_nuxt/{D2O8Q8x9.js → CFCM18fF.js} +1 -1
  192. package/assets/board/_nuxt/CGXljzd_.js +1 -0
  193. package/assets/board/_nuxt/CI0scS-N.js +1 -0
  194. package/assets/board/_nuxt/CJs9B42o.js +1 -0
  195. package/assets/board/_nuxt/COecDWbb.js +1 -0
  196. package/assets/board/_nuxt/COmtTtfB.js +1 -0
  197. package/assets/board/_nuxt/CPYm4J_K.js +1 -0
  198. package/assets/board/_nuxt/CReA4TEB.js +3 -0
  199. package/assets/board/_nuxt/CXG7GZes.js +1 -0
  200. package/assets/board/_nuxt/CYGiZn-X.js +1 -0
  201. package/assets/board/_nuxt/CaE8VlUp.js +1 -0
  202. package/assets/board/_nuxt/CcGiNUgA.js +1 -0
  203. package/assets/board/_nuxt/Ccgv3lDj.js +1 -0
  204. package/assets/board/_nuxt/CdPGFZyy.js +1 -0
  205. package/assets/board/_nuxt/{DfR3IZN_.js → Cdf_T8Xa.js} +1 -1
  206. package/assets/board/_nuxt/CgQ30j_6.js +1 -0
  207. package/assets/board/_nuxt/ChUHb39z.js +1 -0
  208. package/assets/board/_nuxt/Ck0-FqVI.js +1 -0
  209. package/assets/board/_nuxt/CkWKLtjO.js +1 -0
  210. package/assets/board/_nuxt/Cq7hsCKB.js +1 -0
  211. package/assets/board/_nuxt/CtFknnHZ.js +1 -0
  212. package/assets/board/_nuxt/D166WVUG.js +1 -0
  213. package/assets/board/_nuxt/D4Dpzo9e.js +1 -0
  214. package/assets/board/_nuxt/D5i92OZO.js +1 -0
  215. package/assets/board/_nuxt/D6dUp4Nm.js +9 -0
  216. package/assets/board/_nuxt/DFQDFspC.js +1 -0
  217. package/assets/board/_nuxt/DHX2tw9J.js +1 -0
  218. package/assets/board/_nuxt/DJM7u8P3.js +1 -0
  219. package/assets/board/_nuxt/{BPTrRhcw.js → DLr85ebo.js} +1 -1
  220. package/assets/board/_nuxt/DOMM1wfl.js +1 -0
  221. package/assets/board/_nuxt/DU6ArlUN.js +1 -0
  222. package/assets/board/_nuxt/{B_SlFcWp.js → DUgGsde6.js} +1 -1
  223. package/assets/board/_nuxt/DV4ti-Pq.js +1 -0
  224. package/assets/board/_nuxt/DZ--fXuU.js +1 -0
  225. package/assets/board/_nuxt/D_ct-Stz.js +2 -0
  226. package/assets/board/_nuxt/DbWAComO.js +1 -0
  227. package/assets/board/_nuxt/DbwlcHAt.js +1 -0
  228. package/assets/board/_nuxt/DjzLR9Bl.js +1 -0
  229. package/assets/board/_nuxt/Dki9VkAL.js +3 -0
  230. package/assets/board/_nuxt/DlNJhOoc.js +1 -0
  231. package/assets/board/_nuxt/DqG7CtD4.js +1 -0
  232. package/assets/board/_nuxt/DqUSsCJ2.js +1 -0
  233. package/assets/board/_nuxt/Du2F4WXh.js +1 -0
  234. package/assets/board/_nuxt/DuRiEXn4.js +1 -0
  235. package/assets/board/_nuxt/DxBwVNyP.js +2 -0
  236. package/assets/board/_nuxt/DzmVW5-D.js +1 -0
  237. package/assets/board/_nuxt/I-sGtDIB.js +6 -0
  238. package/assets/board/_nuxt/JU9-x68N.js +1 -0
  239. package/assets/board/_nuxt/Lozm7hhE.js +1 -0
  240. package/assets/board/_nuxt/MMsTMkcB.js +1 -0
  241. package/assets/board/_nuxt/MS7CGmCB.js +1 -0
  242. package/assets/board/_nuxt/OKKaKILz.js +1 -0
  243. package/assets/board/_nuxt/{Csqgy925.js → P6qlvBHA.js} +1 -1
  244. package/assets/board/_nuxt/SrNUvNwO.js +1 -0
  245. package/assets/board/_nuxt/{DSaWoFHS.js → SywdkA98.js} +1 -1
  246. package/assets/board/_nuxt/TBIRpVLG.js +1 -0
  247. package/assets/board/_nuxt/TlTrMIMm.js +1 -0
  248. package/assets/board/_nuxt/Zv9-SOQF.js +1 -0
  249. package/assets/board/_nuxt/_id_.ItzoSaBL.css +1 -0
  250. package/assets/board/_nuxt/builds/latest.json +1 -1
  251. package/assets/board/_nuxt/builds/meta/2218461a-f45b-41d6-ae44-b0d2332a9511.json +1 -0
  252. package/assets/board/_nuxt/burndown.CT8fx1Ge.css +1 -0
  253. package/assets/board/_nuxt/diagrams.D1ybqBem.css +1 -0
  254. package/assets/board/_nuxt/{entry.CKHePurH.css → entry.BCuHiFgF.css} +1 -1
  255. package/assets/board/_nuxt/epics.CUeDIEa0.css +1 -0
  256. package/assets/board/_nuxt/error-404.Y9imKiDC.css +1 -0
  257. package/assets/board/_nuxt/error-500.DufSBGVC.css +1 -0
  258. package/assets/board/_nuxt/gantt.CiC2Ts1t.css +1 -0
  259. package/assets/board/_nuxt/jbri6q1p.js +1 -0
  260. package/assets/board/_nuxt/last-change.C05Vc3BF.css +1 -0
  261. package/assets/board/_nuxt/milestones.Civ3sMiQ.css +1 -0
  262. package/assets/board/_nuxt/oSJF4yHw.js +1 -0
  263. package/assets/board/_nuxt/pages.BshWbOyo.css +1 -0
  264. package/assets/board/_nuxt/q_8bxRMt.js +1 -0
  265. package/assets/board/_nuxt/tests.Ci9fBCka.css +1 -0
  266. package/assets/board/burndown/index.html +1 -1
  267. package/assets/board/diagrams/index.html +1 -0
  268. package/assets/board/epics/index.html +1 -1
  269. package/assets/board/favicon.svg +16 -16
  270. package/assets/board/gantt/index.html +1 -1
  271. package/assets/board/index.html +1 -1
  272. package/assets/board/last-change/index.html +1 -1
  273. package/assets/board/milestones/index.html +1 -1
  274. package/assets/board/tests/index.html +1 -1
  275. package/bin/scrum-skills.mjs +204 -191
  276. package/docs/ai-assistant-guide.md +1008 -1008
  277. package/package.json +42 -41
  278. package/templates/AGENTS.md +84 -74
  279. package/tools/agile-tools.mjs +510 -510
  280. package/tools/ai.mjs +464 -464
  281. package/tools/diagram-import.mjs +238 -0
  282. package/tools/diagram-lint.mjs +518 -0
  283. package/tools/diagram.mjs +299 -0
  284. package/tools/git-commit.mjs +216 -216
  285. package/tools/last-change.mjs +326 -326
  286. package/tools/projects.mjs +240 -215
  287. package/tools/serve.mjs +180 -154
  288. package/tools/validate.mjs +781 -664
  289. package/assets/board/_nuxt/1eqboJxR.js +0 -1
  290. package/assets/board/_nuxt/2K7ag6ES.js +0 -1
  291. package/assets/board/_nuxt/51OWHj8c.js +0 -2
  292. package/assets/board/_nuxt/6gFtTBwm.js +0 -1
  293. package/assets/board/_nuxt/B3jkABw9.js +0 -1
  294. package/assets/board/_nuxt/B5n2_jZN.js +0 -1
  295. package/assets/board/_nuxt/BDK2Hj7B.js +0 -1
  296. package/assets/board/_nuxt/BDcnzXHq.js +0 -1
  297. package/assets/board/_nuxt/BaG8y0Wt.js +0 -1
  298. package/assets/board/_nuxt/BbwJq5pb.js +0 -1
  299. package/assets/board/_nuxt/BgLeKwx0.js +0 -1
  300. package/assets/board/_nuxt/Bi9Axj3V.js +0 -1
  301. package/assets/board/_nuxt/BmxvtaMq.js +0 -1
  302. package/assets/board/_nuxt/BodFvto3.js +0 -1
  303. package/assets/board/_nuxt/BpWeHLuN.js +0 -1
  304. package/assets/board/_nuxt/BuOKDFiJ.js +0 -1
  305. package/assets/board/_nuxt/C40iTe-x.js +0 -1
  306. package/assets/board/_nuxt/C7K0R7F-.js +0 -2
  307. package/assets/board/_nuxt/C8vL692X.js +0 -1
  308. package/assets/board/_nuxt/C9StZL3D.js +0 -3
  309. package/assets/board/_nuxt/CGI_ZV79.js +0 -1
  310. package/assets/board/_nuxt/CJ2mz0WY.js +0 -1
  311. package/assets/board/_nuxt/CJMBJQuf.js +0 -1
  312. package/assets/board/_nuxt/CK-lOYG9.js +0 -1
  313. package/assets/board/_nuxt/CK9_veoz.js +0 -1
  314. package/assets/board/_nuxt/CLBcBrYf.js +0 -1
  315. package/assets/board/_nuxt/CMUi2-Lj.js +0 -1
  316. package/assets/board/_nuxt/COf6E6h4.js +0 -1
  317. package/assets/board/_nuxt/CPCUMJnF.js +0 -1
  318. package/assets/board/_nuxt/CPcKlmSm.js +0 -1
  319. package/assets/board/_nuxt/CQTvWh4i.js +0 -1
  320. package/assets/board/_nuxt/CSEEUF8S.js +0 -1
  321. package/assets/board/_nuxt/CSSb154I.js +0 -13
  322. package/assets/board/_nuxt/CgDTd7jS.js +0 -1
  323. package/assets/board/_nuxt/ChG76uh9.js +0 -1
  324. package/assets/board/_nuxt/Ci0qbkgh.js +0 -1
  325. package/assets/board/_nuxt/CqoQ__I_.js +0 -1
  326. package/assets/board/_nuxt/CuJUHLl8.js +0 -1
  327. package/assets/board/_nuxt/CvLiA4_O.js +0 -1
  328. package/assets/board/_nuxt/Cvs12oA0.js +0 -3
  329. package/assets/board/_nuxt/CyEXFe3V.js +0 -1
  330. package/assets/board/_nuxt/Cz6rZcLK.js +0 -1
  331. package/assets/board/_nuxt/D2FhHl1o.js +0 -1
  332. package/assets/board/_nuxt/D3YCAmfu.js +0 -1
  333. package/assets/board/_nuxt/D5CE81jK.js +0 -1
  334. package/assets/board/_nuxt/D75vbfSb.js +0 -1
  335. package/assets/board/_nuxt/D99LbGfb.js +0 -9
  336. package/assets/board/_nuxt/DEfLc7p3.js +0 -6
  337. package/assets/board/_nuxt/DF-nsa1Z.js +0 -1
  338. package/assets/board/_nuxt/DIc8yx98.js +0 -1
  339. package/assets/board/_nuxt/DLkAKotw.js +0 -1
  340. package/assets/board/_nuxt/DRPlYvb3.js +0 -1
  341. package/assets/board/_nuxt/DRlQgul3.js +0 -1
  342. package/assets/board/_nuxt/DSLbmoWa.js +0 -1
  343. package/assets/board/_nuxt/DWO9c9yp.js +0 -1
  344. package/assets/board/_nuxt/DZo3dYLJ.js +0 -3
  345. package/assets/board/_nuxt/D_S6N7_b.js +0 -1
  346. package/assets/board/_nuxt/DajA-Tkt.js +0 -1
  347. package/assets/board/_nuxt/DbHiyab-.js +0 -1
  348. package/assets/board/_nuxt/Dc7Ql6eE.js +0 -1
  349. package/assets/board/_nuxt/Dd9ZukPU.js +0 -1
  350. package/assets/board/_nuxt/Dg2LiqmN.js +0 -1
  351. package/assets/board/_nuxt/DhuleNnm.js +0 -1
  352. package/assets/board/_nuxt/DkI1Qxpp.js +0 -95
  353. package/assets/board/_nuxt/DnDHGSYI.js +0 -1
  354. package/assets/board/_nuxt/DpXJ_tCU.js +0 -1
  355. package/assets/board/_nuxt/DqZgF3hU.js +0 -1
  356. package/assets/board/_nuxt/DqcxvfbD.js +0 -1
  357. package/assets/board/_nuxt/DsA2lVci.js +0 -1
  358. package/assets/board/_nuxt/Dtsa-1NS.js +0 -1
  359. package/assets/board/_nuxt/Du91iq0p.js +0 -1
  360. package/assets/board/_nuxt/Dx1wcYFo.js +0 -1
  361. package/assets/board/_nuxt/EYTShbt4.js +0 -1
  362. package/assets/board/_nuxt/MaXJprwN.js +0 -1
  363. package/assets/board/_nuxt/P9x67aq2.js +0 -1
  364. package/assets/board/_nuxt/S08nFESZ.js +0 -3
  365. package/assets/board/_nuxt/V0uynWE2.js +0 -1
  366. package/assets/board/_nuxt/Vo4wMbnS.js +0 -1
  367. package/assets/board/_nuxt/ZVTqot-_.js +0 -1
  368. package/assets/board/_nuxt/ZngEd3HB.js +0 -1
  369. package/assets/board/_nuxt/_id_.B2NDKSlX.css +0 -1
  370. package/assets/board/_nuxt/assMu_Wn.js +0 -1
  371. package/assets/board/_nuxt/builds/meta/8e23aec9-5b67-4569-b8bf-d4f07e50ffdf.json +0 -1
  372. package/assets/board/_nuxt/burndown.Do6IbM6o.css +0 -1
  373. package/assets/board/_nuxt/epics.Dmessplu.css +0 -1
  374. package/assets/board/_nuxt/error-404.BFmXOnCT.css +0 -1
  375. package/assets/board/_nuxt/error-500.BZ-DdjQ6.css +0 -1
  376. package/assets/board/_nuxt/gantt.Dmp00GUo.css +0 -1
  377. package/assets/board/_nuxt/last-change.1Su4GrCN.css +0 -1
  378. package/assets/board/_nuxt/milestones.U0xKCsEo.css +0 -1
  379. package/assets/board/_nuxt/pages.Dbj2IVk8.css +0 -1
  380. package/assets/board/_nuxt/rlePnzpc.js +0 -1
  381. package/assets/board/_nuxt/tests.dXlUX16A.css +0 -1
  382. package/assets/board/_nuxt/uKzCKCTq.js +0 -1
  383. package/assets/board/_nuxt/vwxaNGfc.js +0 -1
  384. package/assets/board/_nuxt/yh6sf_D5.js +0 -1
@@ -1,1008 +1,1008 @@
1
- # 基于 Nuxt UI 的 AI 助手前端开发指南
2
-
3
- 这份文档只讲**前端**:模型设置的界面与状态、基本对话、消息历史、图片上传、消息策略在浏览器侧落地的方式,以及关键代码。服务端(读配置、拼 system prompt、转发模型、跑工具)视为已存在的黑盒,只约定接口契约。
4
-
5
- 技术基线(本项目实测可用):Nuxt 4 + `@nuxt/ui` 4 + `@comark/nuxt` 0.7。页面是 SPA(`ssr: false`),所有状态留在浏览器。
6
-
7
- ---
8
-
9
- ## 1. 前端只做三件事
10
-
11
- ```
12
- ┌───────────────────────────── 浏览器 ─────────────────────────────┐
13
- │ AiAssistant.vue 悬浮球 + 抽屉 + 设置页 / 对话页切换 │
14
- │ ├─ 设置面板 UInput / UTextarea / USwitch / 预设 chips │
15
- │ ├─ 对话区 UChatMessages + part 分发器 │
16
- │ └─ 输入区 UChatPrompt + 图片 / 新建 / 历史 / 策略 │
17
- │ use-ai.ts 消息、状态机、SSE 解析、localStorage │
18
- └──────────────────────────────────────────────────────────────────┘
19
- │ GET/POST /api/ai/config POST /api/ai/chat (SSE)
20
- ▼
21
- 服务端(黑盒,Key 与模型地址都在那边)
22
- ```
23
-
24
- 前端的三条职责边界,越界就会出问题:
25
-
26
- 1. **不碰 Key**。只读 `hasKey`,写配置时把用户输入原样交给服务端。
27
- 2. **不自己算上下文**。裁剪是服务端的事,前端只负责把历史整理成「文字 + 最近几张图」的合法结构。
28
- 3. **只改 `messages.value` 里的元素**。任何攒起来准备后改的对象都会绕过响应式代理,表现为「流式回答一个字都不刷新」。
29
-
30
- ---
31
-
32
- ## 2. 目录与依赖
33
-
34
- ```
35
- app/
36
- components/AiAssistant.vue 抽屉 + 悬浮球 + 设置面板 + 对话区
37
- components/chat/
38
- ChatComark.ts markdown 流式渲染(defineMarkdownComponent)
39
- MessageContent.vue part 分发器(text/reasoning/tool/image/review)
40
- ReasoningBlock.vue 思考折叠块
41
- composables/use-ai.ts 状态、流式解析、会话历史
42
- assets/css/main.css .ai-* 样式(抽屉骨架、消息、输入区)
43
- ```
44
-
45
- ```jsonc
46
- // package.json
47
- {
48
- "dependencies": {
49
- "@comark/nuxt": "^0.7.0",
50
- "@nuxt/ui": "^4.12.0",
51
- "nuxt": "^4.6.2"
52
- },
53
- "devDependencies": { "@iconify-json/lucide": "^1.2.147" }
54
- }
55
- ```
56
-
57
- ```ts
58
- // nuxt.config.ts
59
- export default defineNuxtConfig({
60
- modules: ['@nuxt/ui', '@comark/nuxt'],
61
- ssr: false, // 单机工具,不需要 SEO,省掉一半水合问题
62
- css: ['~/assets/css/main.css'], // 先 @import "tailwindcss"; 再 @import "@nuxt/ui";
63
- ui: { colorMode: false, fonts: false }, // 不用暗色/在线字体就关掉,避免白屏等字体
64
- })
65
- ```
66
-
67
- `app.vue` 最外层必须是 `<UApp>`,否则 `UPopover` / `UDropdownMenu` 的浮层与 Toast 都不工作:
68
-
69
- ```vue
70
- <template>
71
- <UApp>
72
- <NuxtPage />
73
- <AiAssistant /> <!-- 挂在外壳上,所有页面都能叫出来 -->
74
- </UApp>
75
- </template>
76
- ```
77
-
78
- ---
79
-
80
- ## 3. 与后端的数据契约
81
-
82
- 前端只需要照这个形状读写,不用关心服务端怎么实现。
83
-
84
- ### 3.1 消息与 part
85
-
86
- ```ts
87
- export type AiPart =
88
- | { type: 'text'; text: string }
89
- // startedAt/durationSec 用来算「思考了 N 秒」:第一段思考 → 第一段正文之间的耗时
90
- | { type: 'reasoning'; text: string; startedAt?: number; durationSec?: number }
91
- | { type: 'tool'; toolCallId: string; toolName: string; state: 'running' | 'done' | 'error'; args?: unknown; output?: string }
92
- | { type: 'image'; id: string; url: string; name?: string }
93
- | { type: 'review'; pendingId: string; state: 'pending' | 'applied' | 'rejected' | 'error'; /* 业务字段 */ }
94
-
95
- export interface AiMessage { id: string; role: 'user' | 'assistant'; parts: AiPart[] }
96
- ```
97
-
98
- 为什么不用 `content: string`:一条回答里会交替出现「思考 → 正文 → 工具 → 正文」,用字符串只能靠正则切分,渲染层必然失控。`parts` 也是 `UChatMessages` 认的形状。
99
-
100
- ### 3.2 配置(`GET/POST /api/ai/config`)
101
-
102
- ```ts
103
- export interface AiConfig {
104
- provider: string // 预设 id:deepseek / openai / moonshot / siliconflow / ollama / custom
105
- baseUrl: string // 原样使用,不自动补 /v1
106
- model: string
107
- systemPrompt: string
108
- temperature: number
109
- maxToolRounds: number
110
- contextLimit: number
111
- autoCompact: boolean
112
- reviewMode: 'manual' | 'auto'
113
- hasKey?: boolean // 只读:是否已存 Key(永远拿不到 Key 本身)
114
- presets?: { id: string; label: string; baseUrl: string; model: string }[]
115
- tools?: string[]
116
- }
117
- ```
118
-
119
- 写配置时 `apiKey` 有三种语义,前端要分清:`undefined` = 不改动、字符串 = 设置、`null` = 清除。
120
-
121
- ### 3.3 SSE 事件表
122
-
123
- `POST /api/ai/chat` 返回 `text/event-stream`,每帧 `data: {json}\n\n`:
124
-
125
- | event | 字段 | 前端动作 |
126
- |---|---|---|
127
- | `text` | `delta` | 追加到最后一个 text part(没有就新建) |
128
- | `reasoning` | `delta` | 追加到最后一个 reasoning part,首段记 `startedAt` |
129
- | `tool-call` | `id` `name` `args` | push 一个 `state: 'running'` 的 tool part |
130
- | `tool-result` | `id` `ok` `output` `wrote?` | 对应 tool part 改 `done` / `error` |
131
- | `pending` | `record` | push 待审变更 part(业务可选) |
132
- | `awaiting-approval` | `id` | 服务端已停流,等用户点批准 |
133
- | `error` | `message` | 显示错误并在正文补一句 |
134
- | `done` | `rounds` | 正常收尾 |
135
-
136
- `UChatMessages` 的 `status` 只认 `'ready' | 'submitted' | 'streaming' | 'error'`,前端状态机照这四个值维护。
137
-
138
- ---
139
-
140
- ## 4. 模型设置
141
-
142
- ### 4.1 形态
143
-
144
- 设置与对话**共用一个抽屉**,靠右上角图标来回切;不要把设置做成第二个弹窗(关起来像丢了一层)。
145
-
146
- ```vue
147
- <header class="ai-head">
148
- <b>{{ settingsOpen ? '模型设置' : '看板助手' }}</b>
149
- <button class="ai-icon-btn" @click="settingsOpen = !settingsOpen">
150
- <UIcon :name="settingsOpen ? 'i-lucide-message-square' : 'i-lucide-settings-2'" />
151
- </button>
152
- <button class="ai-icon-btn" @click="open = false"><UIcon name="i-lucide-x" /></button>
153
- </header>
154
- ```
155
-
156
- 首次打开(还没配 Key)**直接落到设置页**,并在悬浮球上挂红点,否则用户点了助手只看到一句「还没配置」:
157
-
158
- ```ts
159
- if (!data.hasKey && data.provider !== 'ollama' && !data.baseUrl?.includes('localhost')) settingsOpen.value = true
160
- ```
161
-
162
- ### 4.2 表单
163
-
164
- ```vue
165
- <script setup lang="ts">
166
- const form = reactive({
167
- provider: 'deepseek',
168
- baseUrl: 'https://api.deepseek.com',
169
- apiKey: '',
170
- model: 'deepseek-chat',
171
- systemPrompt: '',
172
- temperature: 0.3,
173
- maxToolRounds: 30,
174
- contextLimit: 256000,
175
- autoCompact: true,
176
- })
177
- const showKey = ref(false)
178
- const INPUT_UI = { base: 'ai-input' } // 圆角只在 .ai-drawer 下生效,不外溢到别的页面
179
-
180
- /** 点预设:只填服务商自带的地址与模型,用户仍可手改(网关/反代场景) */
181
- function applyPreset(p: AiPreset) {
182
- form.provider = p.id
183
- if (p.baseUrl) { form.baseUrl = p.baseUrl; form.model = p.model }
184
- }
185
-
186
- async function save() {
187
- await saveConfig({
188
- ...form,
189
- temperature: Number(form.temperature),
190
- maxToolRounds: Number(form.maxToolRounds),
191
- contextLimit: Number(form.contextLimit),
192
- // 留空 = 不改动已保存的 Key;要清空走「清除 Key」发 null
193
- apiKey: form.apiKey.trim() || undefined,
194
- })
195
- form.apiKey = '' // 保存后立刻从内存里抹掉,别留在输入框里
196
- }
197
- </script>
198
-
199
- <template>
200
- <div class="ai-scroll">
201
- <p class="ai-banner">配置只存在这台设备上,不写进项目目录,也不回传到浏览器。</p>
202
-
203
- <p class="ai-label">常见服务(点一下填地址)</p>
204
- <div class="ai-chips">
205
- <button v-for="p in presets" :key="p.id" class="ai-chip" :data-on="form.provider === p.id" @click="applyPreset(p)">
206
- {{ p.label }}
207
- </button>
208
- </div>
209
-
210
- <div class="ai-field">
211
- <label>接口地址</label>
212
- <UInput v-model="form.baseUrl" :ui="INPUT_UI" placeholder="https://api.deepseek.com" />
213
- <p class="ai-help">原样使用,不会自动补 /v1 —— 厂商要求带版本号的请自己填全。</p>
214
- </div>
215
-
216
- <div class="ai-field">
217
- <label>API Key</label>
218
- <UInput
219
- v-model="form.apiKey"
220
- :ui="INPUT_UI"
221
- :type="showKey ? 'text' : 'password'"
222
- :placeholder="config.hasKey ? '已保存,留空表示不改动' : 'sk-…'"
223
- :trailing-icon="showKey ? 'i-lucide-eye-off' : 'i-lucide-eye'"
224
- @click:trailing="showKey = !showKey"
225
- />
226
- <p class="ai-help">本地 Ollama 这类不填 Key 的服务可以留空。</p>
227
- </div>
228
-
229
- <div class="ai-field">
230
- <label>系统提示词(可选)</label>
231
- <UTextarea v-model="form.systemPrompt" :ui="INPUT_UI" :rows="3" placeholder="只写你自己的偏好,例如「回答先给结论」。" />
232
- <p class="ai-help">「能做什么」已经内置,不用重复交代。</p>
233
- </div>
234
-
235
- <div class="ai-grid">
236
- <div class="ai-field">
237
- <label>温度</label>
238
- <UInput v-model="form.temperature" :ui="INPUT_UI" type="number" step="0.1" min="0" max="2" />
239
- </div>
240
- <div class="ai-field">
241
- <label>上下文上限(token)</label>
242
- <UInput v-model="form.contextLimit" :ui="INPUT_UI" type="number" min="8000" step="1000" />
243
- </div>
244
- <div class="ai-field">
245
- <label>工具调用轮数上限</label>
246
- <UInput v-model="form.maxToolRounds" :ui="INPUT_UI" type="number" min="1" max="50" />
247
- </div>
248
- </div>
249
-
250
- <label class="ai-check">
251
- <div class="ai-check-txt">
252
- <b>超长时自动压缩老对话</b>
253
- <small>超过上下文上限就先丢掉最早那几轮;内置说明和当前提问永远保留。</small>
254
- </div>
255
- <USwitch v-model="form.autoCompact" />
256
- </label>
257
-
258
- <p v-if="saveError" class="ai-error">{{ saveError }}</p>
259
- <div class="ai-actions">
260
- <button class="btn btn-dark" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存并开始聊天' }}</button>
261
- <button class="btn btn-quiet" @click="clearKey">清除 Key</button>
262
- </div>
263
- </div>
264
- </template>
265
- ```
266
-
267
- `clearKey` 走 `saveConfig({ apiKey: null })`,成功后停在设置页并清空输入框。
268
-
269
- ### 4.3 前端要做的错误兜底
270
-
271
- 服务端会把非法值以 400 + `{ error: '配置非法:温度必须是 0~2 之间的数字' }` 返回。前端统一取 `e?.data?.error || e?.message || '保存失败'` 落到 `saveError`,不要自己再维护一套校验文案 —— 两边规则不一致时用户会看到自相矛盾的两句话。
272
-
273
- ---
274
-
275
- ## 5. 基本对话
276
-
277
- ### 5.1 composable:状态机 + 流式解析
278
-
279
- (片段里的 `newId` 是 `prefix-时间戳-随机串`;`history()` 见 7.3,`persist()` 见第 6 节。)
280
-
281
- ```ts
282
- export function useAi() {
283
- const messages = useState<AiMessage[]>('ai-messages', () => [])
284
- const status = useState<'ready' | 'submitted' | 'streaming' | 'error'>('ai-status', () => 'ready')
285
- const error = useState('ai-error', () => '')
286
- const currentThreadId = useState('ai-thread-id', () => '')
287
- let controller: AbortController | null = null
288
-
289
- /** 只能改数组里的元素(响应式代理)。抓着刚 push 进去的普通对象改,视图不会更新 */
290
- const tail = () => messages.value[messages.value.length - 1]
291
-
292
- function editReply(fn: (m: AiMessage) => void) {
293
- const m = tail()
294
- if (m?.role === 'assistant') fn(m)
295
- }
296
-
297
- /** 第一段正文到来 = 思考段结束,记下耗时给「思考了 N 秒」用 */
298
- function sealReasoning() {
299
- editReply((m) => {
300
- const now = Date.now()
301
- m.parts = m.parts.map(p =>
302
- p.type === 'reasoning' && p.startedAt && !p.durationSec
303
- ? { ...p, durationSec: Math.max(1, Math.round((now - p.startedAt) / 1000)) }
304
- : p,
305
- )
306
- })
307
- }
308
-
309
- function appendText(type: 'text' | 'reasoning', delta: string) {
310
- if (type === 'text') sealReasoning()
311
- editReply((m) => {
312
- const parts = m.parts.slice()
313
- const last = parts[parts.length - 1]
314
- if (last && last.type === type) {
315
- parts[parts.length - 1] = type === 'reasoning'
316
- ? { ...last, text: last.text + delta, startedAt: (last as any).startedAt ?? Date.now() }
317
- : { ...last, text: (last as any).text + delta }
318
- } else {
319
- parts.push(type === 'reasoning' ? { type, text: delta, startedAt: Date.now() } : { type, text: delta })
320
- }
321
- m.parts = parts
322
- })
323
- }
324
-
325
- function pushPart(part: AiPart) {
326
- sealReasoning() // 工具卡/图片之前先把思考段收尾
327
- editReply((m) => { m.parts = [...m.parts, part] })
328
- }
329
-
330
- function settleTool(id: string, ok: boolean, output?: string) {
331
- editReply((m) => {
332
- m.parts = m.parts.map(p =>
333
- p.type === 'tool' && p.toolCallId === id ? { ...p, state: ok ? 'done' : 'error', output } : p,
334
- )
335
- })
336
- }
337
-
338
- async function send(text: string, images: AiImage[] = []) {
339
- const content = text.trim()
340
- if ((!content && !images.length) || status.value === 'submitted' || status.value === 'streaming') return
341
- error.value = ''
342
-
343
- // 这一轮固定认一个会话 id:中途切走也不会把内容写进别的会话
344
- const threadId = currentThreadId.value || newId('t')
345
- currentThreadId.value = threadId
346
-
347
- const parts: AiPart[] = content ? [{ type: 'text', text: content }] : []
348
- for (const img of images) parts.push({ type: 'image', id: img.id, url: img.url, name: img.name })
349
- messages.value = [...messages.value, { id: newId('u'), role: 'user', parts }]
350
- messages.value = [...messages.value, { id: newId('a'), role: 'assistant', parts: [] }] // 先落位空助手消息
351
- status.value = 'submitted'
352
-
353
- controller = new AbortController()
354
- try {
355
- const response = await fetch('/api/ai/chat', {
356
- method: 'POST',
357
- headers: { 'content-type': 'application/json' },
358
- // history() 只收有文字的 part,刚落位的空 assistant 自然被排除;
359
- // 这里再 slice 一刀会把用户刚问的那句切掉 —— 模型看不到问题,只会重复上一轮
360
- body: JSON.stringify({ messages: history() }),
361
- signal: controller.signal,
362
- })
363
- if (!response.ok || !response.body) throw new Error((await response.text()) || `接口返回 ${response.status}`)
364
-
365
- status.value = 'streaming'
366
- const reader = response.body.getReader()
367
- const decoder = new TextDecoder()
368
- let buffer = ''
369
- while (true) {
370
- const { value, done } = await reader.read()
371
- if (done) break
372
- buffer += decoder.decode(value, { stream: true })
373
- const lines = buffer.split('\n')
374
- buffer = lines.pop() ?? '' // 半个 chunk 留着等下一段
375
- for (const raw of lines) {
376
- const line = raw.trim()
377
- if (!line.startsWith('data:')) continue
378
- const payload = line.slice(5).trim()
379
- if (!payload) continue
380
- let evt: any
381
- try { evt = JSON.parse(payload) } catch { continue }
382
-
383
- if (evt.type === 'text') appendText('text', evt.delta)
384
- else if (evt.type === 'reasoning') appendText('reasoning', evt.delta)
385
- else if (evt.type === 'tool-call') {
386
- pushPart({ type: 'tool', toolCallId: evt.id, toolName: evt.name, state: 'running', args: evt.args })
387
- } else if (evt.type === 'tool-result') settleTool(evt.id, !!evt.ok, evt.output)
388
- else if (evt.type === 'error') {
389
- error.value = evt.message
390
- appendText('text', `(出错了:${evt.message})`)
391
- status.value = 'error'
392
- }
393
- }
394
- }
395
- if (status.value !== 'error') status.value = 'ready'
396
- } catch (e: any) {
397
- if (e?.name !== 'AbortError') {
398
- error.value = e?.message || '对话失败'
399
- status.value = 'error'
400
- appendText('text', `(出错了:${error.value})`)
401
- } else {
402
- status.value = 'ready' // 主动停止不算错误
403
- }
404
- } finally {
405
- controller = null
406
- persist(threadId) // 正常/出错/被停都落一次盘
407
- }
408
- }
409
-
410
- function stop() { controller?.abort(); status.value = 'ready' }
411
-
412
- return { messages, status, error, send, stop, /* … */ }
413
- }
414
- ```
415
-
416
- 注意 `fetch` 不用 `EventSource`:需要 POST + 自定义头 + 携带整段历史,`EventSource` 只支持 GET。
417
-
418
- ### 5.2 对话区
419
-
420
- ```vue
421
- <UChatMessages
422
- v-if="messages.length"
423
- :messages="messages"
424
- :status="status"
425
- should-auto-scroll
426
- :spacing-offset="96"
427
- :user="{ variant: 'soft' }"
428
- class="ai-messages"
429
- >
430
- <template #indicator>
431
- <div class="ai-indicator"><UChatShimmer text="思考中…" /></div>
432
- </template>
433
-
434
- <template #content="{ message }">
435
- <ChatMessageContent
436
- :message="message"
437
- :streaming="status === 'streaming' && message.id === messages[messages.length - 1]?.id"
438
- />
439
- </template>
440
- </UChatMessages>
441
- ```
442
-
443
- `#content` 必须接管:默认渲染只画 `text` part,思考、工具、图片、待审变更都出不来。
444
-
445
- `spacing-offset` 是给底部固定输入区留出的滚动余量,不给的话最后一条回答会被输入框压住。
446
-
447
- ### 5.3 part 分发器
448
-
449
- (`labelOf` / `suffixOf` / `argsText` / `resultText` 是业务小函数:工具名的中文映射、参数摘要、参数与结果的格式化文本。)
450
-
451
- ```vue
452
- <template v-for="(part, index) in message.parts" :key="`${message.id}-${index}`">
453
- <ChatReasoningBlock
454
- v-if="part.type === 'reasoning'"
455
- :text="part.text"
456
- :streaming="isPartStreaming(index)"
457
- :duration="part.durationSec"
458
- />
459
-
460
- <UChatTool
461
- v-else-if="part.type === 'tool'"
462
- :text="part.state === 'running' ? `${labelOf(part.toolName)}中…` : labelOf(part.toolName)"
463
- :suffix="suffixOf(part)"
464
- :streaming="part.state === 'running'"
465
- chevron="leading"
466
- >
467
- <div class="ai-tool-io">
468
- <p v-if="argsText(part)" class="ai-tool-label">参数</p>
469
- <pre v-if="argsText(part)" class="ai-tool-body">{{ argsText(part) }}</pre>
470
- <p v-if="resultText(part)" class="ai-tool-label">结果</p>
471
- <pre v-if="resultText(part)" class="ai-tool-body">{{ resultText(part) }}</pre>
472
- </div>
473
- </UChatTool>
474
-
475
- <img v-else-if="part.type === 'image' && part.url" class="ai-msg-img" :src="part.url" :alt="part.name" />
476
- <p v-else-if="part.type === 'image'" class="ai-img-missing">图片未保存(历史会话不存图)</p>
477
-
478
- <div v-else-if="part.type === 'text'" class="ai-md">
479
- <ChatComark v-if="message.role === 'assistant'" :value="part.text" :streaming="isPartStreaming(index)" />
480
- <p v-else class="ai-text">{{ part.text }}</p> <!-- 用户消息走纯文本,保留换行 -->
481
- </div>
482
- </template>
483
- ```
484
-
485
- ```ts
486
- /** 只有最后一段还在长,前面的 part 已定型 —— 流式光标只该出现在末尾 */
487
- const isPartStreaming = (i: number) => !!props.streaming && i === props.message.parts.length - 1
488
- ```
489
-
490
- markdown 用 Comark(AST → Vue 组件,不走 `v-html`,流式增长不会整段重排,也不会把模型输出当 HTML 执行):
491
-
492
- ```ts
493
- // app/components/chat/ChatComark.ts
494
- export default defineMarkdownComponent({
495
- name: 'ChatComark',
496
- class: '*:first:mt-0 *:last:mb-0', // 流式时首个元素的上边距会顶得很难看
497
- })
498
- ```
499
-
500
- ### 5.4 思考折叠块
501
-
502
- 不要直接用 `UChatReasoning`:它的标题走组件库 locale,没装 `@nuxtjs/i18n` 时永远是英文 "Thought for 1 second"。自写一个,交互照官方(流式展开、结束后 600ms 自动收起):
503
-
504
- ```vue
505
- <script setup lang="ts">
506
- const props = defineProps<{ text: string; streaming?: boolean; duration?: number }>()
507
- const open = ref(!props.duration && !!props.streaming)
508
-
509
- watch(() => props.streaming, (streaming, was) => {
510
- if (streaming) open.value = true
511
- else if (was) setTimeout(() => { open.value = false }, 600)
512
- })
513
-
514
- const label = computed(() => {
515
- if (props.streaming) return '思考中…'
516
- if (props.duration) return `思考了 ${props.duration} 秒`
517
- return '思考过程'
518
- })
519
- </script>
520
-
521
- <template>
522
- <div class="ai-think" :data-open="open">
523
- <button class="ai-think-btn" :aria-expanded="open" @click="open = !open">
524
- <UIcon class="ai-think-chev" name="i-lucide-chevron-down" />
525
- <span>{{ label }}</span>
526
- </button>
527
- <div v-show="open" class="ai-think-body">
528
- <ChatComark :value="text" :streaming="streaming" />
529
- </div>
530
- </div>
531
- </template>
532
- ```
533
-
534
- ### 5.5 输入区
535
-
536
- ```vue
537
- <UChatPrompt
538
- v-model="draft"
539
- color="neutral"
540
- variant="subtle"
541
- :rows="1"
542
- :maxrows="6"
543
- :autofocus="false"
544
- placeholder="问点什么…(Enter 发送,截图可直接粘贴)"
545
- :disabled="status === 'streaming'"
546
- @submit="submit"
547
- >
548
- <template #footer>
549
- <div class="ai-composer-left">
550
- <!-- 从左到右:加图片 → 新建会话 → 历史 → 策略;加东西在左,策略在最右 -->
551
- <button class="ai-pick-image" :disabled="adding" @click="pickImages"><UIcon name="i-lucide-image-plus" /></button>
552
- <button class="ai-new-thread" @click="ai.newThread()"><UIcon name="i-lucide-plus" /></button>
553
- <UPopover v-model:open="historyOpen" :content="{ align: 'start', side: 'top' }">…</UPopover>
554
- <UDropdownMenu :items="reviewItems" :content="{ align: 'start', side: 'top' }">
555
- <button class="ai-strategy" :data-mode="config.reviewMode">
556
- <UIcon :name="config.reviewMode === 'manual' ? 'i-lucide-shield-check' : 'i-lucide-zap'" />
557
- </button>
558
- </UDropdownMenu>
559
- </div>
560
-
561
- <div class="ai-composer-right">
562
- <span class="ai-hint">{{ status === 'streaming' ? '正在回答…' : config.model }}</span>
563
- <UChatPromptSubmit
564
- class="ai-stop"
565
- square
566
- :status="status"
567
- :icon="false"
568
- color="neutral"
569
- variant="solid"
570
- size="sm"
571
- @stop="ai.stop()"
572
- >
573
- <svg v-if="status === 'streaming' || status === 'submitted'" viewBox="0 0 24 24" class="size-4 shrink-0">
574
- <rect x="4" y="4" width="16" height="16" rx="2.5" fill="currentColor" />
575
- </svg>
576
- <UIcon v-else-if="status === 'error'" name="i-lucide-rotate-ccw" class="size-4 shrink-0" />
577
- <UIcon v-else name="i-lucide-arrow-up" class="size-4 shrink-0" />
578
- </UChatPromptSubmit>
579
- </div>
580
- </template>
581
- </UChatPrompt>
582
- ```
583
-
584
- ```css
585
- /* ChatPromptSubmit 的 streaming 分支写的是 `streamingIcon || 默认图标`,传 false 挡不住,
586
- 所以三种状态全部从插槽出,库自带的 leadingIcon 用 CSS 藏掉 */
587
- .ai-stop [data-slot='leadingIcon'] { display: none; }
588
-
589
- /* 输入框:外层框交给组件库,里面那层 textarea 自己再描一圈就成了「框里套框」 */
590
- .ai-drawer .ai-prompt textarea,
591
- .ai-drawer .ai-prompt textarea:focus {
592
- border: 0; outline: none; box-shadow: none;
593
- }
594
- ```
595
-
596
- 空状态给 3~4 个「常用问题」卡片(图标 + 问题 + 一句说明),点一下直接发,比一句「问我点什么」有用得多。
597
-
598
- ---
599
-
600
- ## 6. 消息历史
601
-
602
- 浏览器本地存储,服务端一概不知。单机工具不需要账号体系;放服务端就得处理多用户隔离、清理、迁移,收益为零。
603
-
604
- ```ts
605
- const THREAD_KEY = 'your-app:ai:threads:v1'
606
- export const THREAD_LIMIT = 20 // localStorage 只有几 MB
607
-
608
- interface AiThreadRecord { id: string; title: string; createdAt: number; updatedAt: number; messages: AiMessage[] }
609
- interface AiThreadStore { currentId: string | null; threads: AiThreadRecord[] }
610
-
611
- function readStore(): AiThreadStore {
612
- const empty: AiThreadStore = { currentId: null, threads: [] }
613
- if (typeof localStorage === 'undefined') return empty
614
- try {
615
- const parsed = JSON.parse(localStorage.getItem(THREAD_KEY) || '')
616
- const list = Array.isArray(parsed.threads)
617
- ? parsed.threads.filter((t: any) => t && typeof t.id === 'string' && Array.isArray(t.messages))
618
- : []
619
- return { currentId: typeof parsed.currentId === 'string' ? parsed.currentId : null, threads: list.slice(0, THREAD_LIMIT) }
620
- } catch { return empty } // 一条坏 JSON 不该让整个助手打不开
621
- }
622
-
623
- function writeStore(store: AiThreadStore) {
624
- const put = (list: AiThreadRecord[]) =>
625
- localStorage.setItem(THREAD_KEY, JSON.stringify({ currentId: store.currentId, threads: list.slice(0, THREAD_LIMIT) }))
626
- try { put(store.threads) }
627
- catch {
628
- // 配额满了:砍掉一半最老的再试一次;还不行就放弃 —— 历史不值得打断当前对话
629
- try { put(store.threads.slice(0, Math.max(1, Math.ceil(store.threads.length / 2)))) } catch {}
630
- }
631
- }
632
-
633
- /**
634
- * threadId 与当前会话不一致说明中途切走了:宁可不写,也不要把 A 会话的内容盖到 B 上。
635
- */
636
- function persist(threadId = currentThreadId.value || newId('t')) {
637
- if (currentThreadId.value && threadId !== currentThreadId.value) return
638
- if (!messages.value.length) return
639
- currentThreadId.value = threadId
640
- const store = readStore()
641
- const old = store.threads.find(t => t.id === threadId)
642
- store.threads = [
643
- { id: threadId, title: titleOf(messages.value), createdAt: old?.createdAt ?? Date.now(), updatedAt: Date.now(), messages: stripImages(messages.value) },
644
- ...store.threads.filter(t => t.id !== threadId),
645
- ]
646
- store.currentId = threadId
647
- writeStore(store)
648
- syncThreads(store) // 弹层只拿摘要:id / title / updatedAt / count
649
- }
650
- ```
651
-
652
- 配套四件事:
653
-
654
- | 动作 | 注意 |
655
- |---|---|
656
- | 新建会话 | 先 `stop()` + `persist()` 存下当前这轮(可能只有半截回答),再清空 |
657
- | 切换会话 | 先 `stop()` + `persist()`;**深拷贝** `JSON.parse(JSON.stringify(record.messages))`,否则消息代理会改到存档对象上 |
658
- | 删除会话 | 删的正好是当前这条时,对话区一起清空,否则界面停在已不存在的会话上,下次 persist 又写回来 |
659
- | 标题 | 取第一条用户消息前 24 字,没有就「新会话」 |
660
-
661
- 弹层里的时间只分几档(刚刚 / N 分钟前 / N 小时前 / M 月 D 日),别写成 `2026-10-26 11:20:31` 让人自己算。
662
-
663
- 删除要二次确认:第一次点击按钮变红显示「再点一次」,3.2 秒内再点才真删 —— 本地数据删了没法撤销。
664
-
665
- `loadThreads()` 在 `onMounted` 里调一次(恢复会话列表 + 上次那条),`deleteThread` / `openThread` / `newThread` 之后都要同步列表。
666
-
667
- ---
668
-
669
- ## 7. 图片上传
670
-
671
- 三件事:**压缩、限额、历史不存图**。
672
-
673
- ### 7.1 压缩:选图与粘贴走同一条路
674
-
675
- ```ts
676
- const IMAGE_MAX_EDGE = 1400
677
-
678
- export async function fileToAiImage(file: File): Promise<AiImage> {
679
- const name = file.name || '粘贴的图片'
680
- const fallback = async () => ({ id: newId('img'), url: await readAsDataUrl(file), name })
681
- try {
682
- const bitmap = await createImageBitmap(file)
683
- const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
684
- const canvas = document.createElement('canvas')
685
- canvas.width = Math.max(1, Math.round(bitmap.width * scale))
686
- canvas.height = Math.max(1, Math.round(bitmap.height * scale))
687
- const ctx = canvas.getContext('2d')
688
- if (!ctx) return fallback()
689
- // JPEG 没有透明通道:先铺白底,否则截图里的透明区域会变黑
690
- ctx.fillStyle = '#fff'
691
- ctx.fillRect(0, 0, canvas.width, canvas.height)
692
- ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
693
- bitmap.close()
694
- return { id: newId('img'), url: canvas.toDataURL('image/jpeg', 0.86), name }
695
- } catch {
696
- return fallback() // 老浏览器 / 特殊格式:退回原图,别让用户传不了
697
- }
698
- }
699
- ```
700
-
701
- 一张桌面截图 2~3 MB,直接 base64 塞请求既慢又容易顶到厂商体积上限;缩到长边 1400 + JPEG 0.86 通常 100~300 KB,看界面、读报错完全够。
702
-
703
- ### 7.2 两条入口 + 限额
704
-
705
- ```ts
706
- const MAX_IMAGE_ATTACHMENTS = 4
707
-
708
- function pickImages() {
709
- if (attachments.value.length >= MAX_IMAGE_ATTACHMENTS) { error.value = `一次最多带 ${MAX_IMAGE_ATTACHMENTS} 张图片`; return }
710
- // 模板里不放常驻 file input(会破坏抽屉皮肤),用的时候临时造一个
711
- const input = document.createElement('input')
712
- Object.assign(input, { type: 'file', accept: 'image/*', multiple: true })
713
- input.onchange = () => void addImages([...(input.files ?? [])])
714
- input.click()
715
- }
716
-
717
- /** 粘贴截图:paste 从 textarea 冒泡到输入区,只挑剪贴板里的图片文件 */
718
- function onPaste(event: ClipboardEvent) {
719
- const files = [...(event.clipboardData?.items ?? [])]
720
- .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
721
- .map(item => item.getAsFile())
722
- .filter((f): f is File => !!f)
723
- if (!files.length) return
724
- event.preventDefault() // 有图片就别再往输入框里插文本路径
725
- void addImages(files)
726
- }
727
- ```
728
-
729
- 待发图片挂在输入框上方(54×54 缩略图 + 右上角删除角标 + 一行「最多 4 张」,该行只在有附件时出现):
730
-
731
- ```vue
732
- <div v-if="attachments.length" class="ai-attachments">
733
- <figure v-for="image in attachments" :key="image.id" class="ai-attach">
734
- <img :src="image.url" :alt="image.name">
735
- <button class="ai-attach-x" aria-label="移除这张图片" @click="removeImage(image.id)">
736
- <UIcon name="i-lucide-x" />
737
- </button>
738
- </figure>
739
- <p class="ai-attach-tip">最多 {{ MAX_IMAGE_ATTACHMENTS }} 张,随这条提问一起发给模型</p>
740
- </div>
741
- ```
742
-
743
- 发送时把附件随消息一起清空,避免下一次误带:
744
-
745
- ```ts
746
- function submit() {
747
- const text = draft.value
748
- const images = attachments.value
749
- if (!text.trim() && !images.length) return
750
- draft.value = ''
751
- attachments.value = []
752
- ai.send(text, images)
753
- }
754
- ```
755
-
756
- ### 7.3 拼进请求:只带最近 N 张
757
-
758
- ```ts
759
- export const IMAGE_HISTORY_LIMIT = 4
760
-
761
- function history() {
762
- const keep = new Set<string>()
763
- for (let i = messages.value.length - 1; i >= 0 && keep.size < IMAGE_HISTORY_LIMIT; i -= 1) {
764
- for (const p of messages.value[i].parts) if (p.type === 'image' && keep.size < IMAGE_HISTORY_LIMIT) keep.add(p.id)
765
- }
766
-
767
- const out = []
768
- for (const m of messages.value) {
769
- const text = m.parts.filter(p => p.type === 'text').map(p => p.text).join('')
770
- const images = m.parts.filter(p => p.type === 'image')
771
- // 从历史恢复的图片 url 是空的(落盘时清掉了),空 url 拼进请求就是非法 image_url
772
- const sent = images.filter(img => keep.has(img.id) && img.url)
773
- const dropped = images.length - sent.length
774
- const note = dropped > 0 ? `(之前附的 ${dropped} 张图片已省略)` : ''
775
- if (!text.trim() && !sent.length) continue
776
- if (!sent.length) { out.push({ role: m.role, content: text.trim() ? `${text}\n${note}` : note }); continue }
777
- out.push({
778
- role: m.role,
779
- content: [
780
- ...(text.trim() ? [{ type: 'text', text }] : []),
781
- ...(note ? [{ type: 'text', text: note }] : []),
782
- ...sent.map(img => ({ type: 'image_url', image_url: { url: img.url } })),
783
- ],
784
- })
785
- }
786
- return out
787
- }
788
- ```
789
-
790
- 文字上下文一个字不丢,但请求体不会被历史截图堆爆。
791
-
792
- ### 7.4 落盘时清空 url
793
-
794
- ```ts
795
- /** 一张压缩过的截图 base64 上百 KB,几个会话就能撑爆 localStorage */
796
- function stripImages(messages: AiMessage[]): AiMessage[] {
797
- return messages.map(m => ({
798
- ...m,
799
- parts: m.parts.map(p => (p.type === 'image' ? { ...p, url: '' } : p)),
800
- }))
801
- }
802
- ```
803
-
804
- 清空 url 而不是删掉 part:恢复后还能在原位显示「图片未保存」占位,用户知道当时发过图(渲染分支见 5.3)。
805
-
806
- ---
807
-
808
- ## 8. 消息策略(前端侧)
809
-
810
- 裁剪与压缩由服务端执行,前端要保证三件事,否则服务端再聪明也救不回来。
811
-
812
- ### 8.1 交给模型的只有「文字 + 最近几张图」
813
-
814
- `history()`(见 7.3)必须:
815
-
816
- - 跳过空的 assistant 消息(刚落位、还没收到任何 delta);
817
- - 把 reasoning / tool / review part 排除在外 —— 它们是给人看的,塞回模型只会让上下文暴涨;
818
- - 图片只带最近 4 张,其余换一句「已省略」占位;
819
- - **不要**对 `messages.value` 再做 `slice`。多切一刀就会把用户刚问的那句切掉,模型看不到问题,只会重复上一轮。
820
-
821
- ### 8.2 状态机不能中途误判
822
-
823
- `status` 只有四个值,但含义要分清:
824
-
825
- | 值 | 含义 | 界面表现 |
826
- |---|---|---|
827
- | `ready` | 空闲,可以发下一条 | 发送按钮(箭头) |
828
- | `submitted` | 请求已发出,还没收到第一帧 | 停止按钮 + 指示器「思考中…」 |
829
- | `streaming` | 正在收 delta | 停止按钮 + 末尾流式光标 |
830
- | `error` | 接口或模型报错 | 重试图标 + 输入区下方红字 |
831
-
832
- `awaiting-approval` 到达后服务端已停流,此时**不要**把 `status` 当成 `streaming`:改成 `ready`,另用一个 `awaiting` 变量渲染输入框上方的提示条「已停在这里等你审,点批准或驳回后我才会继续」。否则用户会一直看到转圈。
833
-
834
- ### 8.3 审核策略的交互
835
-
836
- `reviewMode` 是高频操作,放输入框左下角的图标按钮 + `UDropdownMenu`,不要埋进设置页:
837
-
838
- ```ts
839
- const reviewItems = computed(() => [[
840
- {
841
- label: '手动审核',
842
- description: '改数据前先出待审卡,你点批准才写入',
843
- icon: config.value.reviewMode === 'manual' ? 'i-lucide-check' : 'i-lucide-shield-check',
844
- onSelect: () => ai.setReviewMode('manual'),
845
- },
846
- {
847
- label: '自动审核',
848
- description: '直接写入并跑校验,校验不过自动回滚',
849
- icon: config.value.reviewMode === 'auto' ? 'i-lucide-check' : 'i-lucide-zap',
850
- onSelect: () => ai.setReviewMode('auto'),
851
- },
852
- ]])
853
- ```
854
-
855
- 待审卡(`review` part)要点:diff 的每一行都显示 `路径 / 之前 → 之后`,两个按钮「批准并写入」「驳回」,处理完后原地换成状态文案(已写入并通过校验 / 已驳回,文件未改动),不再可点。
856
-
857
- ```ts
858
- async function resolvePending(pendingId: string, approve: boolean) {
859
- awaiting.value = ''
860
- try {
861
- if (approve) {
862
- await post('/api/ai/pending/apply', { id: pendingId })
863
- settleReview(pendingId, 'applied', '已写入并通过校验')
864
- await send(`【审核】已批准变更 ${pendingId},请确认写入结果并继续。`)
865
- } else {
866
- await post('/api/ai/pending/reject', { id: pendingId })
867
- settleReview(pendingId, 'rejected', '已驳回,文件未改动')
868
- await send(`【审核】已驳回变更 ${pendingId},请不要写入,换个方案或先问我。`)
869
- }
870
- } catch (e: any) {
871
- settleReview(pendingId, 'error', e?.data?.error || e?.message || '审核操作失败')
872
- }
873
- }
874
- ```
875
-
876
- 批准/驳回之后补一条消息让模型接着干,这样「停流 → 人审 → 续跑」才闭环。
877
-
878
- ### 8.4 幂等:一轮只认一个会话 id
879
-
880
- `send()` 开头固定 `threadId`,`finally` 里 `persist(threadId)`。用户中途切会话、删会话都不会把这一轮写到别处去。
881
-
882
- ---
883
-
884
- ## 9. 布局与样式要点
885
-
886
- ### 9.1 抽屉自己画,不用 UDrawer
887
-
888
- `UDrawer`(vaul)在 `direction=right` 时无条件挂拖拽手势:既吞掉正文的框选,又会在拖选时把整块面板推着走。自绘一个固定面板即可:
889
-
890
- ```css
891
- .ai-scrim { position: fixed; inset: 0; z-index: 60; background: rgba(23,24,27,.16); }
892
- .ai-drawer {
893
- position: fixed; top: 0; right: 0; bottom: 0; z-index: 61;
894
- display: flex; flex-direction: column;
895
- width: min(430px, 100vw);
896
- background: #fff; border-left: 1px solid var(--line);
897
- }
898
- .ai-sheet-enter-from, .ai-sheet-leave-to { transform: translate3d(100%, 0, 0); }
899
- ```
900
-
901
- 配 `<Teleport to="body">` + `<Transition name="ai-sheet">`,打开时锁背景滚动:
902
-
903
- ```ts
904
- watch(open, v => { if (import.meta.client) document.documentElement.style.overflow = v ? 'hidden' : '' })
905
- onBeforeUnmount(() => { if (import.meta.client) document.documentElement.style.overflow = '' }) // 不还原,这一页从此滚不动
906
- ```
907
-
908
- 抽屉内的下拉菜单会被压在面板下面,因为 reka 的浮层用行内 `z-index: auto`:
909
-
910
- ```css
911
- [data-reka-popper-content-wrapper] { z-index: 70 !important; } /* 行内样式只能 !important 压 */
912
- ```
913
-
914
- ### 9.2 `UChatMessages` 的容器
915
-
916
- 它的 body 是 `min-w-0` 的 flex 子项,外层不给 `width:100%` 就会把正文压成窄条:
917
-
918
- ```css
919
- .ai-messages { width: 100%; min-width: 0; }
920
- ```
921
-
922
- ### 9.3 控件圆角只作用于抽屉内
923
-
924
- `UInput` / `UTextarea` 的样式覆盖通过 `:ui` 传类名,选择器一律挂在 `.ai-drawer` 下,别外溢到别的页面:
925
-
926
- ```ts
927
- const INPUT_UI = { base: 'ai-input' }
928
- const PROMPT_UI = { root: 'ai-prompt', base: ['px-1.5'] }
929
- ```
930
-
931
- ---
932
-
933
- ## 10. 坑清单
934
-
935
- | 现象 | 原因 | 处理 |
936
- |---|---|---|
937
- | 流式回答一个字都不刷,最后一次性蹦出来 | 抓着「push 进数组前捕获的普通对象」改,绕过响应式代理 | 一律通过 `messages.value` 里的元素改(`tail()` + `editReply`) |
938
- | 思考块永远是英文 "Thought for 1 second" | `UChatReasoning` 文案走组件库 locale | 自写折叠块(5.4) |
939
- | 停止按钮画成空心方块 | `ChatPromptSubmit` 写的是 `streamingIcon \|\| 默认图标`,传 `false` 挡不住 | 三态全部插槽自绘 + 隐藏 `data-slot='leadingIcon'` |
940
- | 抽屉里正文无法框选、拖选时面板跟着动 | `UDrawer`(vaul) 的拖拽手势 | 自绘固定面板(9.1) |
941
- | 抽屉里的下拉菜单点开像没反应 | reka 浮层 z-index 低于自绘抽屉 | `[data-reka-popper-content-wrapper] { z-index: 70 !important }` |
942
- | 最后一条回答被输入框压住 | 没给滚动余量 | `:spacing-offset="96"` |
943
- | 恢复历史会话后请求 400 | 图片 url 已被清空,空 url 拼进 `image_url` 非法 | `filter(img => img.url)`,缺的换文字占位 |
944
- | 带图请求失败,用户以为是 Key 或网络 | 模型不是多模态 | 服务端已附提示时原样展示;前端报错区不要改写成「网络错误」 |
945
- | 用户还没批就看到后续输出 | 待审后前端没停 | 收到 `pending` 立即 `status = 'ready'` + 显示 `awaiting` 提示条 |
946
- | 删了当前会话,界面还停在它上面 | 没同时清空对话区 | 删当前会话时 `messages = []`、`currentThreadId = ''` |
947
- | 切会话后消息错乱 / 互相覆盖 | 上一轮的 `persist` 落到新会话上 | `persist(threadId)` 校验 id,不一致直接不写 |
948
- | 切会话后消息改不动 | 直接赋值存档对象,代理改了原对象 | `JSON.parse(JSON.stringify(...))` 深拷贝 |
949
- | localStorage 写满,之后所有写操作抛异常 | 会话里存了 base64 图片 | 落盘 `stripImages`;写入失败砍半重试 |
950
- | 弹层关闭后某一行还亮着「确认删除」 | 待删状态没复位 | `watch(historyOpen, v => !v && resetPendingDelete())` |
951
- | 抽屉关掉后整页滚不动 | `overflow:hidden` 只在打开时设,没还原 | `onBeforeUnmount` 还原 |
952
- | 发第二条时把第一张图又带上 | 附件没在 `submit` 里清空 | 提交后 `attachments = []` |
953
-
954
- ---
955
-
956
- ## 11. 验收清单(前端)
957
-
958
- **模型设置**
959
-
960
- 1. 首次打开(无 Key)自动停在设置页,悬浮球有红点。
961
- 2. 点预设 → 地址与模型名自动填好;手改地址后保存,刷新仍在。
962
- 3. 页面上任何地方都拿不到 Key(DevTools 看 `/api/ai/config` 响应只有 `hasKey`)。
963
- 4. 保存成功后输入框里的 Key 被清空;「清除 Key」后回到设置页且红点重新出现。
964
- 5. 服务端返回 400 时,错误文案显示在设置页内,不弹全局 alert。
965
-
966
- **基本对话**
967
-
968
- 6. 回答逐字出现,首字前有「思考中…」指示器。
969
- 7. 点停止 → 立刻停,`status` 回 `ready`,已收到的内容保留。
970
- 8. 思考模型:思考内容落在折叠块里,正文开始后自动收起并显示「思考了 N 秒」。
971
- 9. 工具调用:先出现「xx中…」,结果回来后变成「xx」,展开能看参数与结果。
972
- 10. 报错时正文补一句「(出错了:…)」且输入区下方有红字,发送按钮变重试图标。
973
-
974
- **消息历史**
975
-
976
- 11. 聊几句 → 新建会话 → 历史弹层出现上一条,标题是第一句前 24 字。
977
- 12. 切回旧会话,消息与 part 类型(含工具卡)完整恢复。
978
- 13. 删除需两次点击;删当前会话后对话区同步清空。
979
- 14. 刷新页面恢复上次会话;时间显示为「刚刚 / N 分钟前」。
980
-
981
- **图片**
982
-
983
- 15. 上传 3 MB 截图 → 缩略图正常,请求体明显变小(DevTools 看 payload)。
984
- 16. 输入框里 Ctrl+V 粘贴截图 → 变成附件,不插入文本。
985
- 17. 发第 5 张 → 提示上限 4 张。
986
- 18. 历史会话里的图片显示占位,且不会发出空 url。
987
-
988
- **策略与交互**
989
-
990
- 19. 手动审核:待审卡出现后指示器停止转圈,输入框上方出现「等你审」提示条;批准后自动续跑。
991
- 20. 驳回后模型换方案继续,文件未被改动(文案明确)。
992
- 21. 自动审核:没有待审卡,直接显示「已写入并通过校验」。
993
- 22. 审核策略切换后,图标与下拉里的勾选项一致,刷新后保持。
994
- 23. 弹出浮层不被抽屉遮挡;抽屉关闭后页面可正常滚动。
995
-
996
- ---
997
-
998
- ## 12. 移植到新项目
999
-
1000
- | 文件 | 保留 | 替换 |
1001
- |---|---|---|
1002
- | `composables/use-ai.ts` | 状态机、流式解析、`history()`、会话历史 | 业务 part(如 `review`)可整段删 |
1003
- | `AiAssistant.vue` | 抽屉骨架、设置面板、输入区、历史弹层 | 空状态示例问题、业务卡片渲染 |
1004
- | `chat/MessageContent.vue` | part 分发结构 | `TOOL_NAME` 映射、业务 part 分支 |
1005
- | `chat/ReasoningBlock.vue` / `ChatComark.ts` | 原样搬 | — |
1006
- | `main.css` 的 `.ai-*` | 抽屉、消息、输入区骨架 | 配色与圆角换成本项目 token |
1007
-
1008
- 接口侧只需满足第 3 节的契约(config 掩码 + SSE 事件表),前端一行不用改。
1
+ # 基于 Nuxt UI 的 AI 助手前端开发指南
2
+
3
+ 这份文档只讲**前端**:模型设置的界面与状态、基本对话、消息历史、图片上传、消息策略在浏览器侧落地的方式,以及关键代码。服务端(读配置、拼 system prompt、转发模型、跑工具)视为已存在的黑盒,只约定接口契约。
4
+
5
+ 技术基线(本项目实测可用):Nuxt 4 + `@nuxt/ui` 4 + `@comark/nuxt` 0.7。页面是 SPA(`ssr: false`),所有状态留在浏览器。
6
+
7
+ ---
8
+
9
+ ## 1. 前端只做三件事
10
+
11
+ ```
12
+ ┌───────────────────────────── 浏览器 ─────────────────────────────┐
13
+ │ AiAssistant.vue 悬浮球 + 抽屉 + 设置页 / 对话页切换 │
14
+ │ ├─ 设置面板 UInput / UTextarea / USwitch / 预设 chips │
15
+ │ ├─ 对话区 UChatMessages + part 分发器 │
16
+ │ └─ 输入区 UChatPrompt + 图片 / 新建 / 历史 / 策略 │
17
+ │ use-ai.ts 消息、状态机、SSE 解析、localStorage │
18
+ └──────────────────────────────────────────────────────────────────┘
19
+ │ GET/POST /api/ai/config POST /api/ai/chat (SSE)
20
+ ▼
21
+ 服务端(黑盒,Key 与模型地址都在那边)
22
+ ```
23
+
24
+ 前端的三条职责边界,越界就会出问题:
25
+
26
+ 1. **不碰 Key**。只读 `hasKey`,写配置时把用户输入原样交给服务端。
27
+ 2. **不自己算上下文**。裁剪是服务端的事,前端只负责把历史整理成「文字 + 最近几张图」的合法结构。
28
+ 3. **只改 `messages.value` 里的元素**。任何攒起来准备后改的对象都会绕过响应式代理,表现为「流式回答一个字都不刷新」。
29
+
30
+ ---
31
+
32
+ ## 2. 目录与依赖
33
+
34
+ ```
35
+ app/
36
+ components/AiAssistant.vue 抽屉 + 悬浮球 + 设置面板 + 对话区
37
+ components/chat/
38
+ ChatComark.ts markdown 流式渲染(defineMarkdownComponent)
39
+ MessageContent.vue part 分发器(text/reasoning/tool/image/review)
40
+ ReasoningBlock.vue 思考折叠块
41
+ composables/use-ai.ts 状态、流式解析、会话历史
42
+ assets/css/main.css .ai-* 样式(抽屉骨架、消息、输入区)
43
+ ```
44
+
45
+ ```jsonc
46
+ // package.json
47
+ {
48
+ "dependencies": {
49
+ "@comark/nuxt": "^0.7.0",
50
+ "@nuxt/ui": "^4.12.0",
51
+ "nuxt": "^4.6.2"
52
+ },
53
+ "devDependencies": { "@iconify-json/lucide": "^1.2.147" }
54
+ }
55
+ ```
56
+
57
+ ```ts
58
+ // nuxt.config.ts
59
+ export default defineNuxtConfig({
60
+ modules: ['@nuxt/ui', '@comark/nuxt'],
61
+ ssr: false, // 单机工具,不需要 SEO,省掉一半水合问题
62
+ css: ['~/assets/css/main.css'], // 先 @import "tailwindcss"; 再 @import "@nuxt/ui";
63
+ ui: { colorMode: false, fonts: false }, // 不用暗色/在线字体就关掉,避免白屏等字体
64
+ })
65
+ ```
66
+
67
+ `app.vue` 最外层必须是 `<UApp>`,否则 `UPopover` / `UDropdownMenu` 的浮层与 Toast 都不工作:
68
+
69
+ ```vue
70
+ <template>
71
+ <UApp>
72
+ <NuxtPage />
73
+ <AiAssistant /> <!-- 挂在外壳上,所有页面都能叫出来 -->
74
+ </UApp>
75
+ </template>
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 3. 与后端的数据契约
81
+
82
+ 前端只需要照这个形状读写,不用关心服务端怎么实现。
83
+
84
+ ### 3.1 消息与 part
85
+
86
+ ```ts
87
+ export type AiPart =
88
+ | { type: 'text'; text: string }
89
+ // startedAt/durationSec 用来算「思考了 N 秒」:第一段思考 → 第一段正文之间的耗时
90
+ | { type: 'reasoning'; text: string; startedAt?: number; durationSec?: number }
91
+ | { type: 'tool'; toolCallId: string; toolName: string; state: 'running' | 'done' | 'error'; args?: unknown; output?: string }
92
+ | { type: 'image'; id: string; url: string; name?: string }
93
+ | { type: 'review'; pendingId: string; state: 'pending' | 'applied' | 'rejected' | 'error'; /* 业务字段 */ }
94
+
95
+ export interface AiMessage { id: string; role: 'user' | 'assistant'; parts: AiPart[] }
96
+ ```
97
+
98
+ 为什么不用 `content: string`:一条回答里会交替出现「思考 → 正文 → 工具 → 正文」,用字符串只能靠正则切分,渲染层必然失控。`parts` 也是 `UChatMessages` 认的形状。
99
+
100
+ ### 3.2 配置(`GET/POST /api/ai/config`)
101
+
102
+ ```ts
103
+ export interface AiConfig {
104
+ provider: string // 预设 id:deepseek / openai / moonshot / siliconflow / ollama / custom
105
+ baseUrl: string // 原样使用,不自动补 /v1
106
+ model: string
107
+ systemPrompt: string
108
+ temperature: number
109
+ maxToolRounds: number
110
+ contextLimit: number
111
+ autoCompact: boolean
112
+ reviewMode: 'manual' | 'auto'
113
+ hasKey?: boolean // 只读:是否已存 Key(永远拿不到 Key 本身)
114
+ presets?: { id: string; label: string; baseUrl: string; model: string }[]
115
+ tools?: string[]
116
+ }
117
+ ```
118
+
119
+ 写配置时 `apiKey` 有三种语义,前端要分清:`undefined` = 不改动、字符串 = 设置、`null` = 清除。
120
+
121
+ ### 3.3 SSE 事件表
122
+
123
+ `POST /api/ai/chat` 返回 `text/event-stream`,每帧 `data: {json}\n\n`:
124
+
125
+ | event | 字段 | 前端动作 |
126
+ |---|---|---|
127
+ | `text` | `delta` | 追加到最后一个 text part(没有就新建) |
128
+ | `reasoning` | `delta` | 追加到最后一个 reasoning part,首段记 `startedAt` |
129
+ | `tool-call` | `id` `name` `args` | push 一个 `state: 'running'` 的 tool part |
130
+ | `tool-result` | `id` `ok` `output` `wrote?` | 对应 tool part 改 `done` / `error` |
131
+ | `pending` | `record` | push 待审变更 part(业务可选) |
132
+ | `awaiting-approval` | `id` | 服务端已停流,等用户点批准 |
133
+ | `error` | `message` | 显示错误并在正文补一句 |
134
+ | `done` | `rounds` | 正常收尾 |
135
+
136
+ `UChatMessages` 的 `status` 只认 `'ready' | 'submitted' | 'streaming' | 'error'`,前端状态机照这四个值维护。
137
+
138
+ ---
139
+
140
+ ## 4. 模型设置
141
+
142
+ ### 4.1 形态
143
+
144
+ 设置与对话**共用一个抽屉**,靠右上角图标来回切;不要把设置做成第二个弹窗(关起来像丢了一层)。
145
+
146
+ ```vue
147
+ <header class="ai-head">
148
+ <b>{{ settingsOpen ? '模型设置' : '看板助手' }}</b>
149
+ <button class="ai-icon-btn" @click="settingsOpen = !settingsOpen">
150
+ <UIcon :name="settingsOpen ? 'i-lucide-message-square' : 'i-lucide-settings-2'" />
151
+ </button>
152
+ <button class="ai-icon-btn" @click="open = false"><UIcon name="i-lucide-x" /></button>
153
+ </header>
154
+ ```
155
+
156
+ 首次打开(还没配 Key)**直接落到设置页**,并在悬浮球上挂红点,否则用户点了助手只看到一句「还没配置」:
157
+
158
+ ```ts
159
+ if (!data.hasKey && data.provider !== 'ollama' && !data.baseUrl?.includes('localhost')) settingsOpen.value = true
160
+ ```
161
+
162
+ ### 4.2 表单
163
+
164
+ ```vue
165
+ <script setup lang="ts">
166
+ const form = reactive({
167
+ provider: 'deepseek',
168
+ baseUrl: 'https://api.deepseek.com',
169
+ apiKey: '',
170
+ model: 'deepseek-chat',
171
+ systemPrompt: '',
172
+ temperature: 0.3,
173
+ maxToolRounds: 30,
174
+ contextLimit: 256000,
175
+ autoCompact: true,
176
+ })
177
+ const showKey = ref(false)
178
+ const INPUT_UI = { base: 'ai-input' } // 圆角只在 .ai-drawer 下生效,不外溢到别的页面
179
+
180
+ /** 点预设:只填服务商自带的地址与模型,用户仍可手改(网关/反代场景) */
181
+ function applyPreset(p: AiPreset) {
182
+ form.provider = p.id
183
+ if (p.baseUrl) { form.baseUrl = p.baseUrl; form.model = p.model }
184
+ }
185
+
186
+ async function save() {
187
+ await saveConfig({
188
+ ...form,
189
+ temperature: Number(form.temperature),
190
+ maxToolRounds: Number(form.maxToolRounds),
191
+ contextLimit: Number(form.contextLimit),
192
+ // 留空 = 不改动已保存的 Key;要清空走「清除 Key」发 null
193
+ apiKey: form.apiKey.trim() || undefined,
194
+ })
195
+ form.apiKey = '' // 保存后立刻从内存里抹掉,别留在输入框里
196
+ }
197
+ </script>
198
+
199
+ <template>
200
+ <div class="ai-scroll">
201
+ <p class="ai-banner">配置只存在这台设备上,不写进项目目录,也不回传到浏览器。</p>
202
+
203
+ <p class="ai-label">常见服务(点一下填地址)</p>
204
+ <div class="ai-chips">
205
+ <button v-for="p in presets" :key="p.id" class="ai-chip" :data-on="form.provider === p.id" @click="applyPreset(p)">
206
+ {{ p.label }}
207
+ </button>
208
+ </div>
209
+
210
+ <div class="ai-field">
211
+ <label>接口地址</label>
212
+ <UInput v-model="form.baseUrl" :ui="INPUT_UI" placeholder="https://api.deepseek.com" />
213
+ <p class="ai-help">原样使用,不会自动补 /v1 —— 厂商要求带版本号的请自己填全。</p>
214
+ </div>
215
+
216
+ <div class="ai-field">
217
+ <label>API Key</label>
218
+ <UInput
219
+ v-model="form.apiKey"
220
+ :ui="INPUT_UI"
221
+ :type="showKey ? 'text' : 'password'"
222
+ :placeholder="config.hasKey ? '已保存,留空表示不改动' : 'sk-…'"
223
+ :trailing-icon="showKey ? 'i-lucide-eye-off' : 'i-lucide-eye'"
224
+ @click:trailing="showKey = !showKey"
225
+ />
226
+ <p class="ai-help">本地 Ollama 这类不填 Key 的服务可以留空。</p>
227
+ </div>
228
+
229
+ <div class="ai-field">
230
+ <label>系统提示词(可选)</label>
231
+ <UTextarea v-model="form.systemPrompt" :ui="INPUT_UI" :rows="3" placeholder="只写你自己的偏好,例如「回答先给结论」。" />
232
+ <p class="ai-help">「能做什么」已经内置,不用重复交代。</p>
233
+ </div>
234
+
235
+ <div class="ai-grid">
236
+ <div class="ai-field">
237
+ <label>温度</label>
238
+ <UInput v-model="form.temperature" :ui="INPUT_UI" type="number" step="0.1" min="0" max="2" />
239
+ </div>
240
+ <div class="ai-field">
241
+ <label>上下文上限(token)</label>
242
+ <UInput v-model="form.contextLimit" :ui="INPUT_UI" type="number" min="8000" step="1000" />
243
+ </div>
244
+ <div class="ai-field">
245
+ <label>工具调用轮数上限</label>
246
+ <UInput v-model="form.maxToolRounds" :ui="INPUT_UI" type="number" min="1" max="50" />
247
+ </div>
248
+ </div>
249
+
250
+ <label class="ai-check">
251
+ <div class="ai-check-txt">
252
+ <b>超长时自动压缩老对话</b>
253
+ <small>超过上下文上限就先丢掉最早那几轮;内置说明和当前提问永远保留。</small>
254
+ </div>
255
+ <USwitch v-model="form.autoCompact" />
256
+ </label>
257
+
258
+ <p v-if="saveError" class="ai-error">{{ saveError }}</p>
259
+ <div class="ai-actions">
260
+ <button class="btn btn-dark" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存并开始聊天' }}</button>
261
+ <button class="btn btn-quiet" @click="clearKey">清除 Key</button>
262
+ </div>
263
+ </div>
264
+ </template>
265
+ ```
266
+
267
+ `clearKey` 走 `saveConfig({ apiKey: null })`,成功后停在设置页并清空输入框。
268
+
269
+ ### 4.3 前端要做的错误兜底
270
+
271
+ 服务端会把非法值以 400 + `{ error: '配置非法:温度必须是 0~2 之间的数字' }` 返回。前端统一取 `e?.data?.error || e?.message || '保存失败'` 落到 `saveError`,不要自己再维护一套校验文案 —— 两边规则不一致时用户会看到自相矛盾的两句话。
272
+
273
+ ---
274
+
275
+ ## 5. 基本对话
276
+
277
+ ### 5.1 composable:状态机 + 流式解析
278
+
279
+ (片段里的 `newId` 是 `prefix-时间戳-随机串`;`history()` 见 7.3,`persist()` 见第 6 节。)
280
+
281
+ ```ts
282
+ export function useAi() {
283
+ const messages = useState<AiMessage[]>('ai-messages', () => [])
284
+ const status = useState<'ready' | 'submitted' | 'streaming' | 'error'>('ai-status', () => 'ready')
285
+ const error = useState('ai-error', () => '')
286
+ const currentThreadId = useState('ai-thread-id', () => '')
287
+ let controller: AbortController | null = null
288
+
289
+ /** 只能改数组里的元素(响应式代理)。抓着刚 push 进去的普通对象改,视图不会更新 */
290
+ const tail = () => messages.value[messages.value.length - 1]
291
+
292
+ function editReply(fn: (m: AiMessage) => void) {
293
+ const m = tail()
294
+ if (m?.role === 'assistant') fn(m)
295
+ }
296
+
297
+ /** 第一段正文到来 = 思考段结束,记下耗时给「思考了 N 秒」用 */
298
+ function sealReasoning() {
299
+ editReply((m) => {
300
+ const now = Date.now()
301
+ m.parts = m.parts.map(p =>
302
+ p.type === 'reasoning' && p.startedAt && !p.durationSec
303
+ ? { ...p, durationSec: Math.max(1, Math.round((now - p.startedAt) / 1000)) }
304
+ : p,
305
+ )
306
+ })
307
+ }
308
+
309
+ function appendText(type: 'text' | 'reasoning', delta: string) {
310
+ if (type === 'text') sealReasoning()
311
+ editReply((m) => {
312
+ const parts = m.parts.slice()
313
+ const last = parts[parts.length - 1]
314
+ if (last && last.type === type) {
315
+ parts[parts.length - 1] = type === 'reasoning'
316
+ ? { ...last, text: last.text + delta, startedAt: (last as any).startedAt ?? Date.now() }
317
+ : { ...last, text: (last as any).text + delta }
318
+ } else {
319
+ parts.push(type === 'reasoning' ? { type, text: delta, startedAt: Date.now() } : { type, text: delta })
320
+ }
321
+ m.parts = parts
322
+ })
323
+ }
324
+
325
+ function pushPart(part: AiPart) {
326
+ sealReasoning() // 工具卡/图片之前先把思考段收尾
327
+ editReply((m) => { m.parts = [...m.parts, part] })
328
+ }
329
+
330
+ function settleTool(id: string, ok: boolean, output?: string) {
331
+ editReply((m) => {
332
+ m.parts = m.parts.map(p =>
333
+ p.type === 'tool' && p.toolCallId === id ? { ...p, state: ok ? 'done' : 'error', output } : p,
334
+ )
335
+ })
336
+ }
337
+
338
+ async function send(text: string, images: AiImage[] = []) {
339
+ const content = text.trim()
340
+ if ((!content && !images.length) || status.value === 'submitted' || status.value === 'streaming') return
341
+ error.value = ''
342
+
343
+ // 这一轮固定认一个会话 id:中途切走也不会把内容写进别的会话
344
+ const threadId = currentThreadId.value || newId('t')
345
+ currentThreadId.value = threadId
346
+
347
+ const parts: AiPart[] = content ? [{ type: 'text', text: content }] : []
348
+ for (const img of images) parts.push({ type: 'image', id: img.id, url: img.url, name: img.name })
349
+ messages.value = [...messages.value, { id: newId('u'), role: 'user', parts }]
350
+ messages.value = [...messages.value, { id: newId('a'), role: 'assistant', parts: [] }] // 先落位空助手消息
351
+ status.value = 'submitted'
352
+
353
+ controller = new AbortController()
354
+ try {
355
+ const response = await fetch('/api/ai/chat', {
356
+ method: 'POST',
357
+ headers: { 'content-type': 'application/json' },
358
+ // history() 只收有文字的 part,刚落位的空 assistant 自然被排除;
359
+ // 这里再 slice 一刀会把用户刚问的那句切掉 —— 模型看不到问题,只会重复上一轮
360
+ body: JSON.stringify({ messages: history() }),
361
+ signal: controller.signal,
362
+ })
363
+ if (!response.ok || !response.body) throw new Error((await response.text()) || `接口返回 ${response.status}`)
364
+
365
+ status.value = 'streaming'
366
+ const reader = response.body.getReader()
367
+ const decoder = new TextDecoder()
368
+ let buffer = ''
369
+ while (true) {
370
+ const { value, done } = await reader.read()
371
+ if (done) break
372
+ buffer += decoder.decode(value, { stream: true })
373
+ const lines = buffer.split('\n')
374
+ buffer = lines.pop() ?? '' // 半个 chunk 留着等下一段
375
+ for (const raw of lines) {
376
+ const line = raw.trim()
377
+ if (!line.startsWith('data:')) continue
378
+ const payload = line.slice(5).trim()
379
+ if (!payload) continue
380
+ let evt: any
381
+ try { evt = JSON.parse(payload) } catch { continue }
382
+
383
+ if (evt.type === 'text') appendText('text', evt.delta)
384
+ else if (evt.type === 'reasoning') appendText('reasoning', evt.delta)
385
+ else if (evt.type === 'tool-call') {
386
+ pushPart({ type: 'tool', toolCallId: evt.id, toolName: evt.name, state: 'running', args: evt.args })
387
+ } else if (evt.type === 'tool-result') settleTool(evt.id, !!evt.ok, evt.output)
388
+ else if (evt.type === 'error') {
389
+ error.value = evt.message
390
+ appendText('text', `(出错了:${evt.message})`)
391
+ status.value = 'error'
392
+ }
393
+ }
394
+ }
395
+ if (status.value !== 'error') status.value = 'ready'
396
+ } catch (e: any) {
397
+ if (e?.name !== 'AbortError') {
398
+ error.value = e?.message || '对话失败'
399
+ status.value = 'error'
400
+ appendText('text', `(出错了:${error.value})`)
401
+ } else {
402
+ status.value = 'ready' // 主动停止不算错误
403
+ }
404
+ } finally {
405
+ controller = null
406
+ persist(threadId) // 正常/出错/被停都落一次盘
407
+ }
408
+ }
409
+
410
+ function stop() { controller?.abort(); status.value = 'ready' }
411
+
412
+ return { messages, status, error, send, stop, /* … */ }
413
+ }
414
+ ```
415
+
416
+ 注意 `fetch` 不用 `EventSource`:需要 POST + 自定义头 + 携带整段历史,`EventSource` 只支持 GET。
417
+
418
+ ### 5.2 对话区
419
+
420
+ ```vue
421
+ <UChatMessages
422
+ v-if="messages.length"
423
+ :messages="messages"
424
+ :status="status"
425
+ should-auto-scroll
426
+ :spacing-offset="96"
427
+ :user="{ variant: 'soft' }"
428
+ class="ai-messages"
429
+ >
430
+ <template #indicator>
431
+ <div class="ai-indicator"><UChatShimmer text="思考中…" /></div>
432
+ </template>
433
+
434
+ <template #content="{ message }">
435
+ <ChatMessageContent
436
+ :message="message"
437
+ :streaming="status === 'streaming' && message.id === messages[messages.length - 1]?.id"
438
+ />
439
+ </template>
440
+ </UChatMessages>
441
+ ```
442
+
443
+ `#content` 必须接管:默认渲染只画 `text` part,思考、工具、图片、待审变更都出不来。
444
+
445
+ `spacing-offset` 是给底部固定输入区留出的滚动余量,不给的话最后一条回答会被输入框压住。
446
+
447
+ ### 5.3 part 分发器
448
+
449
+ (`labelOf` / `suffixOf` / `argsText` / `resultText` 是业务小函数:工具名的中文映射、参数摘要、参数与结果的格式化文本。)
450
+
451
+ ```vue
452
+ <template v-for="(part, index) in message.parts" :key="`${message.id}-${index}`">
453
+ <ChatReasoningBlock
454
+ v-if="part.type === 'reasoning'"
455
+ :text="part.text"
456
+ :streaming="isPartStreaming(index)"
457
+ :duration="part.durationSec"
458
+ />
459
+
460
+ <UChatTool
461
+ v-else-if="part.type === 'tool'"
462
+ :text="part.state === 'running' ? `${labelOf(part.toolName)}中…` : labelOf(part.toolName)"
463
+ :suffix="suffixOf(part)"
464
+ :streaming="part.state === 'running'"
465
+ chevron="leading"
466
+ >
467
+ <div class="ai-tool-io">
468
+ <p v-if="argsText(part)" class="ai-tool-label">参数</p>
469
+ <pre v-if="argsText(part)" class="ai-tool-body">{{ argsText(part) }}</pre>
470
+ <p v-if="resultText(part)" class="ai-tool-label">结果</p>
471
+ <pre v-if="resultText(part)" class="ai-tool-body">{{ resultText(part) }}</pre>
472
+ </div>
473
+ </UChatTool>
474
+
475
+ <img v-else-if="part.type === 'image' && part.url" class="ai-msg-img" :src="part.url" :alt="part.name" />
476
+ <p v-else-if="part.type === 'image'" class="ai-img-missing">图片未保存(历史会话不存图)</p>
477
+
478
+ <div v-else-if="part.type === 'text'" class="ai-md">
479
+ <ChatComark v-if="message.role === 'assistant'" :value="part.text" :streaming="isPartStreaming(index)" />
480
+ <p v-else class="ai-text">{{ part.text }}</p> <!-- 用户消息走纯文本,保留换行 -->
481
+ </div>
482
+ </template>
483
+ ```
484
+
485
+ ```ts
486
+ /** 只有最后一段还在长,前面的 part 已定型 —— 流式光标只该出现在末尾 */
487
+ const isPartStreaming = (i: number) => !!props.streaming && i === props.message.parts.length - 1
488
+ ```
489
+
490
+ markdown 用 Comark(AST → Vue 组件,不走 `v-html`,流式增长不会整段重排,也不会把模型输出当 HTML 执行):
491
+
492
+ ```ts
493
+ // app/components/chat/ChatComark.ts
494
+ export default defineMarkdownComponent({
495
+ name: 'ChatComark',
496
+ class: '*:first:mt-0 *:last:mb-0', // 流式时首个元素的上边距会顶得很难看
497
+ })
498
+ ```
499
+
500
+ ### 5.4 思考折叠块
501
+
502
+ 不要直接用 `UChatReasoning`:它的标题走组件库 locale,没装 `@nuxtjs/i18n` 时永远是英文 "Thought for 1 second"。自写一个,交互照官方(流式展开、结束后 600ms 自动收起):
503
+
504
+ ```vue
505
+ <script setup lang="ts">
506
+ const props = defineProps<{ text: string; streaming?: boolean; duration?: number }>()
507
+ const open = ref(!props.duration && !!props.streaming)
508
+
509
+ watch(() => props.streaming, (streaming, was) => {
510
+ if (streaming) open.value = true
511
+ else if (was) setTimeout(() => { open.value = false }, 600)
512
+ })
513
+
514
+ const label = computed(() => {
515
+ if (props.streaming) return '思考中…'
516
+ if (props.duration) return `思考了 ${props.duration} 秒`
517
+ return '思考过程'
518
+ })
519
+ </script>
520
+
521
+ <template>
522
+ <div class="ai-think" :data-open="open">
523
+ <button class="ai-think-btn" :aria-expanded="open" @click="open = !open">
524
+ <UIcon class="ai-think-chev" name="i-lucide-chevron-down" />
525
+ <span>{{ label }}</span>
526
+ </button>
527
+ <div v-show="open" class="ai-think-body">
528
+ <ChatComark :value="text" :streaming="streaming" />
529
+ </div>
530
+ </div>
531
+ </template>
532
+ ```
533
+
534
+ ### 5.5 输入区
535
+
536
+ ```vue
537
+ <UChatPrompt
538
+ v-model="draft"
539
+ color="neutral"
540
+ variant="subtle"
541
+ :rows="1"
542
+ :maxrows="6"
543
+ :autofocus="false"
544
+ placeholder="问点什么…(Enter 发送,截图可直接粘贴)"
545
+ :disabled="status === 'streaming'"
546
+ @submit="submit"
547
+ >
548
+ <template #footer>
549
+ <div class="ai-composer-left">
550
+ <!-- 从左到右:加图片 → 新建会话 → 历史 → 策略;加东西在左,策略在最右 -->
551
+ <button class="ai-pick-image" :disabled="adding" @click="pickImages"><UIcon name="i-lucide-image-plus" /></button>
552
+ <button class="ai-new-thread" @click="ai.newThread()"><UIcon name="i-lucide-plus" /></button>
553
+ <UPopover v-model:open="historyOpen" :content="{ align: 'start', side: 'top' }">…</UPopover>
554
+ <UDropdownMenu :items="reviewItems" :content="{ align: 'start', side: 'top' }">
555
+ <button class="ai-strategy" :data-mode="config.reviewMode">
556
+ <UIcon :name="config.reviewMode === 'manual' ? 'i-lucide-shield-check' : 'i-lucide-zap'" />
557
+ </button>
558
+ </UDropdownMenu>
559
+ </div>
560
+
561
+ <div class="ai-composer-right">
562
+ <span class="ai-hint">{{ status === 'streaming' ? '正在回答…' : config.model }}</span>
563
+ <UChatPromptSubmit
564
+ class="ai-stop"
565
+ square
566
+ :status="status"
567
+ :icon="false"
568
+ color="neutral"
569
+ variant="solid"
570
+ size="sm"
571
+ @stop="ai.stop()"
572
+ >
573
+ <svg v-if="status === 'streaming' || status === 'submitted'" viewBox="0 0 24 24" class="size-4 shrink-0">
574
+ <rect x="4" y="4" width="16" height="16" rx="2.5" fill="currentColor" />
575
+ </svg>
576
+ <UIcon v-else-if="status === 'error'" name="i-lucide-rotate-ccw" class="size-4 shrink-0" />
577
+ <UIcon v-else name="i-lucide-arrow-up" class="size-4 shrink-0" />
578
+ </UChatPromptSubmit>
579
+ </div>
580
+ </template>
581
+ </UChatPrompt>
582
+ ```
583
+
584
+ ```css
585
+ /* ChatPromptSubmit 的 streaming 分支写的是 `streamingIcon || 默认图标`,传 false 挡不住,
586
+ 所以三种状态全部从插槽出,库自带的 leadingIcon 用 CSS 藏掉 */
587
+ .ai-stop [data-slot='leadingIcon'] { display: none; }
588
+
589
+ /* 输入框:外层框交给组件库,里面那层 textarea 自己再描一圈就成了「框里套框」 */
590
+ .ai-drawer .ai-prompt textarea,
591
+ .ai-drawer .ai-prompt textarea:focus {
592
+ border: 0; outline: none; box-shadow: none;
593
+ }
594
+ ```
595
+
596
+ 空状态给 3~4 个「常用问题」卡片(图标 + 问题 + 一句说明),点一下直接发,比一句「问我点什么」有用得多。
597
+
598
+ ---
599
+
600
+ ## 6. 消息历史
601
+
602
+ 浏览器本地存储,服务端一概不知。单机工具不需要账号体系;放服务端就得处理多用户隔离、清理、迁移,收益为零。
603
+
604
+ ```ts
605
+ const THREAD_KEY = 'your-app:ai:threads:v1'
606
+ export const THREAD_LIMIT = 20 // localStorage 只有几 MB
607
+
608
+ interface AiThreadRecord { id: string; title: string; createdAt: number; updatedAt: number; messages: AiMessage[] }
609
+ interface AiThreadStore { currentId: string | null; threads: AiThreadRecord[] }
610
+
611
+ function readStore(): AiThreadStore {
612
+ const empty: AiThreadStore = { currentId: null, threads: [] }
613
+ if (typeof localStorage === 'undefined') return empty
614
+ try {
615
+ const parsed = JSON.parse(localStorage.getItem(THREAD_KEY) || '')
616
+ const list = Array.isArray(parsed.threads)
617
+ ? parsed.threads.filter((t: any) => t && typeof t.id === 'string' && Array.isArray(t.messages))
618
+ : []
619
+ return { currentId: typeof parsed.currentId === 'string' ? parsed.currentId : null, threads: list.slice(0, THREAD_LIMIT) }
620
+ } catch { return empty } // 一条坏 JSON 不该让整个助手打不开
621
+ }
622
+
623
+ function writeStore(store: AiThreadStore) {
624
+ const put = (list: AiThreadRecord[]) =>
625
+ localStorage.setItem(THREAD_KEY, JSON.stringify({ currentId: store.currentId, threads: list.slice(0, THREAD_LIMIT) }))
626
+ try { put(store.threads) }
627
+ catch {
628
+ // 配额满了:砍掉一半最老的再试一次;还不行就放弃 —— 历史不值得打断当前对话
629
+ try { put(store.threads.slice(0, Math.max(1, Math.ceil(store.threads.length / 2)))) } catch {}
630
+ }
631
+ }
632
+
633
+ /**
634
+ * threadId 与当前会话不一致说明中途切走了:宁可不写,也不要把 A 会话的内容盖到 B 上。
635
+ */
636
+ function persist(threadId = currentThreadId.value || newId('t')) {
637
+ if (currentThreadId.value && threadId !== currentThreadId.value) return
638
+ if (!messages.value.length) return
639
+ currentThreadId.value = threadId
640
+ const store = readStore()
641
+ const old = store.threads.find(t => t.id === threadId)
642
+ store.threads = [
643
+ { id: threadId, title: titleOf(messages.value), createdAt: old?.createdAt ?? Date.now(), updatedAt: Date.now(), messages: stripImages(messages.value) },
644
+ ...store.threads.filter(t => t.id !== threadId),
645
+ ]
646
+ store.currentId = threadId
647
+ writeStore(store)
648
+ syncThreads(store) // 弹层只拿摘要:id / title / updatedAt / count
649
+ }
650
+ ```
651
+
652
+ 配套四件事:
653
+
654
+ | 动作 | 注意 |
655
+ |---|---|
656
+ | 新建会话 | 先 `stop()` + `persist()` 存下当前这轮(可能只有半截回答),再清空 |
657
+ | 切换会话 | 先 `stop()` + `persist()`;**深拷贝** `JSON.parse(JSON.stringify(record.messages))`,否则消息代理会改到存档对象上 |
658
+ | 删除会话 | 删的正好是当前这条时,对话区一起清空,否则界面停在已不存在的会话上,下次 persist 又写回来 |
659
+ | 标题 | 取第一条用户消息前 24 字,没有就「新会话」 |
660
+
661
+ 弹层里的时间只分几档(刚刚 / N 分钟前 / N 小时前 / M 月 D 日),别写成 `2026-10-26 11:20:31` 让人自己算。
662
+
663
+ 删除要二次确认:第一次点击按钮变红显示「再点一次」,3.2 秒内再点才真删 —— 本地数据删了没法撤销。
664
+
665
+ `loadThreads()` 在 `onMounted` 里调一次(恢复会话列表 + 上次那条),`deleteThread` / `openThread` / `newThread` 之后都要同步列表。
666
+
667
+ ---
668
+
669
+ ## 7. 图片上传
670
+
671
+ 三件事:**压缩、限额、历史不存图**。
672
+
673
+ ### 7.1 压缩:选图与粘贴走同一条路
674
+
675
+ ```ts
676
+ const IMAGE_MAX_EDGE = 1400
677
+
678
+ export async function fileToAiImage(file: File): Promise<AiImage> {
679
+ const name = file.name || '粘贴的图片'
680
+ const fallback = async () => ({ id: newId('img'), url: await readAsDataUrl(file), name })
681
+ try {
682
+ const bitmap = await createImageBitmap(file)
683
+ const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
684
+ const canvas = document.createElement('canvas')
685
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale))
686
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale))
687
+ const ctx = canvas.getContext('2d')
688
+ if (!ctx) return fallback()
689
+ // JPEG 没有透明通道:先铺白底,否则截图里的透明区域会变黑
690
+ ctx.fillStyle = '#fff'
691
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
692
+ ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
693
+ bitmap.close()
694
+ return { id: newId('img'), url: canvas.toDataURL('image/jpeg', 0.86), name }
695
+ } catch {
696
+ return fallback() // 老浏览器 / 特殊格式:退回原图,别让用户传不了
697
+ }
698
+ }
699
+ ```
700
+
701
+ 一张桌面截图 2~3 MB,直接 base64 塞请求既慢又容易顶到厂商体积上限;缩到长边 1400 + JPEG 0.86 通常 100~300 KB,看界面、读报错完全够。
702
+
703
+ ### 7.2 两条入口 + 限额
704
+
705
+ ```ts
706
+ const MAX_IMAGE_ATTACHMENTS = 4
707
+
708
+ function pickImages() {
709
+ if (attachments.value.length >= MAX_IMAGE_ATTACHMENTS) { error.value = `一次最多带 ${MAX_IMAGE_ATTACHMENTS} 张图片`; return }
710
+ // 模板里不放常驻 file input(会破坏抽屉皮肤),用的时候临时造一个
711
+ const input = document.createElement('input')
712
+ Object.assign(input, { type: 'file', accept: 'image/*', multiple: true })
713
+ input.onchange = () => void addImages([...(input.files ?? [])])
714
+ input.click()
715
+ }
716
+
717
+ /** 粘贴截图:paste 从 textarea 冒泡到输入区,只挑剪贴板里的图片文件 */
718
+ function onPaste(event: ClipboardEvent) {
719
+ const files = [...(event.clipboardData?.items ?? [])]
720
+ .filter(item => item.kind === 'file' && item.type.startsWith('image/'))
721
+ .map(item => item.getAsFile())
722
+ .filter((f): f is File => !!f)
723
+ if (!files.length) return
724
+ event.preventDefault() // 有图片就别再往输入框里插文本路径
725
+ void addImages(files)
726
+ }
727
+ ```
728
+
729
+ 待发图片挂在输入框上方(54×54 缩略图 + 右上角删除角标 + 一行「最多 4 张」,该行只在有附件时出现):
730
+
731
+ ```vue
732
+ <div v-if="attachments.length" class="ai-attachments">
733
+ <figure v-for="image in attachments" :key="image.id" class="ai-attach">
734
+ <img :src="image.url" :alt="image.name">
735
+ <button class="ai-attach-x" aria-label="移除这张图片" @click="removeImage(image.id)">
736
+ <UIcon name="i-lucide-x" />
737
+ </button>
738
+ </figure>
739
+ <p class="ai-attach-tip">最多 {{ MAX_IMAGE_ATTACHMENTS }} 张,随这条提问一起发给模型</p>
740
+ </div>
741
+ ```
742
+
743
+ 发送时把附件随消息一起清空,避免下一次误带:
744
+
745
+ ```ts
746
+ function submit() {
747
+ const text = draft.value
748
+ const images = attachments.value
749
+ if (!text.trim() && !images.length) return
750
+ draft.value = ''
751
+ attachments.value = []
752
+ ai.send(text, images)
753
+ }
754
+ ```
755
+
756
+ ### 7.3 拼进请求:只带最近 N 张
757
+
758
+ ```ts
759
+ export const IMAGE_HISTORY_LIMIT = 4
760
+
761
+ function history() {
762
+ const keep = new Set<string>()
763
+ for (let i = messages.value.length - 1; i >= 0 && keep.size < IMAGE_HISTORY_LIMIT; i -= 1) {
764
+ for (const p of messages.value[i].parts) if (p.type === 'image' && keep.size < IMAGE_HISTORY_LIMIT) keep.add(p.id)
765
+ }
766
+
767
+ const out = []
768
+ for (const m of messages.value) {
769
+ const text = m.parts.filter(p => p.type === 'text').map(p => p.text).join('')
770
+ const images = m.parts.filter(p => p.type === 'image')
771
+ // 从历史恢复的图片 url 是空的(落盘时清掉了),空 url 拼进请求就是非法 image_url
772
+ const sent = images.filter(img => keep.has(img.id) && img.url)
773
+ const dropped = images.length - sent.length
774
+ const note = dropped > 0 ? `(之前附的 ${dropped} 张图片已省略)` : ''
775
+ if (!text.trim() && !sent.length) continue
776
+ if (!sent.length) { out.push({ role: m.role, content: text.trim() ? `${text}\n${note}` : note }); continue }
777
+ out.push({
778
+ role: m.role,
779
+ content: [
780
+ ...(text.trim() ? [{ type: 'text', text }] : []),
781
+ ...(note ? [{ type: 'text', text: note }] : []),
782
+ ...sent.map(img => ({ type: 'image_url', image_url: { url: img.url } })),
783
+ ],
784
+ })
785
+ }
786
+ return out
787
+ }
788
+ ```
789
+
790
+ 文字上下文一个字不丢,但请求体不会被历史截图堆爆。
791
+
792
+ ### 7.4 落盘时清空 url
793
+
794
+ ```ts
795
+ /** 一张压缩过的截图 base64 上百 KB,几个会话就能撑爆 localStorage */
796
+ function stripImages(messages: AiMessage[]): AiMessage[] {
797
+ return messages.map(m => ({
798
+ ...m,
799
+ parts: m.parts.map(p => (p.type === 'image' ? { ...p, url: '' } : p)),
800
+ }))
801
+ }
802
+ ```
803
+
804
+ 清空 url 而不是删掉 part:恢复后还能在原位显示「图片未保存」占位,用户知道当时发过图(渲染分支见 5.3)。
805
+
806
+ ---
807
+
808
+ ## 8. 消息策略(前端侧)
809
+
810
+ 裁剪与压缩由服务端执行,前端要保证三件事,否则服务端再聪明也救不回来。
811
+
812
+ ### 8.1 交给模型的只有「文字 + 最近几张图」
813
+
814
+ `history()`(见 7.3)必须:
815
+
816
+ - 跳过空的 assistant 消息(刚落位、还没收到任何 delta);
817
+ - 把 reasoning / tool / review part 排除在外 —— 它们是给人看的,塞回模型只会让上下文暴涨;
818
+ - 图片只带最近 4 张,其余换一句「已省略」占位;
819
+ - **不要**对 `messages.value` 再做 `slice`。多切一刀就会把用户刚问的那句切掉,模型看不到问题,只会重复上一轮。
820
+
821
+ ### 8.2 状态机不能中途误判
822
+
823
+ `status` 只有四个值,但含义要分清:
824
+
825
+ | 值 | 含义 | 界面表现 |
826
+ |---|---|---|
827
+ | `ready` | 空闲,可以发下一条 | 发送按钮(箭头) |
828
+ | `submitted` | 请求已发出,还没收到第一帧 | 停止按钮 + 指示器「思考中…」 |
829
+ | `streaming` | 正在收 delta | 停止按钮 + 末尾流式光标 |
830
+ | `error` | 接口或模型报错 | 重试图标 + 输入区下方红字 |
831
+
832
+ `awaiting-approval` 到达后服务端已停流,此时**不要**把 `status` 当成 `streaming`:改成 `ready`,另用一个 `awaiting` 变量渲染输入框上方的提示条「已停在这里等你审,点批准或驳回后我才会继续」。否则用户会一直看到转圈。
833
+
834
+ ### 8.3 审核策略的交互
835
+
836
+ `reviewMode` 是高频操作,放输入框左下角的图标按钮 + `UDropdownMenu`,不要埋进设置页:
837
+
838
+ ```ts
839
+ const reviewItems = computed(() => [[
840
+ {
841
+ label: '手动审核',
842
+ description: '改数据前先出待审卡,你点批准才写入',
843
+ icon: config.value.reviewMode === 'manual' ? 'i-lucide-check' : 'i-lucide-shield-check',
844
+ onSelect: () => ai.setReviewMode('manual'),
845
+ },
846
+ {
847
+ label: '自动审核',
848
+ description: '直接写入并跑校验,校验不过自动回滚',
849
+ icon: config.value.reviewMode === 'auto' ? 'i-lucide-check' : 'i-lucide-zap',
850
+ onSelect: () => ai.setReviewMode('auto'),
851
+ },
852
+ ]])
853
+ ```
854
+
855
+ 待审卡(`review` part)要点:diff 的每一行都显示 `路径 / 之前 → 之后`,两个按钮「批准并写入」「驳回」,处理完后原地换成状态文案(已写入并通过校验 / 已驳回,文件未改动),不再可点。
856
+
857
+ ```ts
858
+ async function resolvePending(pendingId: string, approve: boolean) {
859
+ awaiting.value = ''
860
+ try {
861
+ if (approve) {
862
+ await post('/api/ai/pending/apply', { id: pendingId })
863
+ settleReview(pendingId, 'applied', '已写入并通过校验')
864
+ await send(`【审核】已批准变更 ${pendingId},请确认写入结果并继续。`)
865
+ } else {
866
+ await post('/api/ai/pending/reject', { id: pendingId })
867
+ settleReview(pendingId, 'rejected', '已驳回,文件未改动')
868
+ await send(`【审核】已驳回变更 ${pendingId},请不要写入,换个方案或先问我。`)
869
+ }
870
+ } catch (e: any) {
871
+ settleReview(pendingId, 'error', e?.data?.error || e?.message || '审核操作失败')
872
+ }
873
+ }
874
+ ```
875
+
876
+ 批准/驳回之后补一条消息让模型接着干,这样「停流 → 人审 → 续跑」才闭环。
877
+
878
+ ### 8.4 幂等:一轮只认一个会话 id
879
+
880
+ `send()` 开头固定 `threadId`,`finally` 里 `persist(threadId)`。用户中途切会话、删会话都不会把这一轮写到别处去。
881
+
882
+ ---
883
+
884
+ ## 9. 布局与样式要点
885
+
886
+ ### 9.1 抽屉自己画,不用 UDrawer
887
+
888
+ `UDrawer`(vaul)在 `direction=right` 时无条件挂拖拽手势:既吞掉正文的框选,又会在拖选时把整块面板推着走。自绘一个固定面板即可:
889
+
890
+ ```css
891
+ .ai-scrim { position: fixed; inset: 0; z-index: 60; background: rgba(23,24,27,.16); }
892
+ .ai-drawer {
893
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 61;
894
+ display: flex; flex-direction: column;
895
+ width: min(430px, 100vw);
896
+ background: #fff; border-left: 1px solid var(--line);
897
+ }
898
+ .ai-sheet-enter-from, .ai-sheet-leave-to { transform: translate3d(100%, 0, 0); }
899
+ ```
900
+
901
+ 配 `<Teleport to="body">` + `<Transition name="ai-sheet">`,打开时锁背景滚动:
902
+
903
+ ```ts
904
+ watch(open, v => { if (import.meta.client) document.documentElement.style.overflow = v ? 'hidden' : '' })
905
+ onBeforeUnmount(() => { if (import.meta.client) document.documentElement.style.overflow = '' }) // 不还原,这一页从此滚不动
906
+ ```
907
+
908
+ 抽屉内的下拉菜单会被压在面板下面,因为 reka 的浮层用行内 `z-index: auto`:
909
+
910
+ ```css
911
+ [data-reka-popper-content-wrapper] { z-index: 70 !important; } /* 行内样式只能 !important 压 */
912
+ ```
913
+
914
+ ### 9.2 `UChatMessages` 的容器
915
+
916
+ 它的 body 是 `min-w-0` 的 flex 子项,外层不给 `width:100%` 就会把正文压成窄条:
917
+
918
+ ```css
919
+ .ai-messages { width: 100%; min-width: 0; }
920
+ ```
921
+
922
+ ### 9.3 控件圆角只作用于抽屉内
923
+
924
+ `UInput` / `UTextarea` 的样式覆盖通过 `:ui` 传类名,选择器一律挂在 `.ai-drawer` 下,别外溢到别的页面:
925
+
926
+ ```ts
927
+ const INPUT_UI = { base: 'ai-input' }
928
+ const PROMPT_UI = { root: 'ai-prompt', base: ['px-1.5'] }
929
+ ```
930
+
931
+ ---
932
+
933
+ ## 10. 坑清单
934
+
935
+ | 现象 | 原因 | 处理 |
936
+ |---|---|---|
937
+ | 流式回答一个字都不刷,最后一次性蹦出来 | 抓着「push 进数组前捕获的普通对象」改,绕过响应式代理 | 一律通过 `messages.value` 里的元素改(`tail()` + `editReply`) |
938
+ | 思考块永远是英文 "Thought for 1 second" | `UChatReasoning` 文案走组件库 locale | 自写折叠块(5.4) |
939
+ | 停止按钮画成空心方块 | `ChatPromptSubmit` 写的是 `streamingIcon \|\| 默认图标`,传 `false` 挡不住 | 三态全部插槽自绘 + 隐藏 `data-slot='leadingIcon'` |
940
+ | 抽屉里正文无法框选、拖选时面板跟着动 | `UDrawer`(vaul) 的拖拽手势 | 自绘固定面板(9.1) |
941
+ | 抽屉里的下拉菜单点开像没反应 | reka 浮层 z-index 低于自绘抽屉 | `[data-reka-popper-content-wrapper] { z-index: 70 !important }` |
942
+ | 最后一条回答被输入框压住 | 没给滚动余量 | `:spacing-offset="96"` |
943
+ | 恢复历史会话后请求 400 | 图片 url 已被清空,空 url 拼进 `image_url` 非法 | `filter(img => img.url)`,缺的换文字占位 |
944
+ | 带图请求失败,用户以为是 Key 或网络 | 模型不是多模态 | 服务端已附提示时原样展示;前端报错区不要改写成「网络错误」 |
945
+ | 用户还没批就看到后续输出 | 待审后前端没停 | 收到 `pending` 立即 `status = 'ready'` + 显示 `awaiting` 提示条 |
946
+ | 删了当前会话,界面还停在它上面 | 没同时清空对话区 | 删当前会话时 `messages = []`、`currentThreadId = ''` |
947
+ | 切会话后消息错乱 / 互相覆盖 | 上一轮的 `persist` 落到新会话上 | `persist(threadId)` 校验 id,不一致直接不写 |
948
+ | 切会话后消息改不动 | 直接赋值存档对象,代理改了原对象 | `JSON.parse(JSON.stringify(...))` 深拷贝 |
949
+ | localStorage 写满,之后所有写操作抛异常 | 会话里存了 base64 图片 | 落盘 `stripImages`;写入失败砍半重试 |
950
+ | 弹层关闭后某一行还亮着「确认删除」 | 待删状态没复位 | `watch(historyOpen, v => !v && resetPendingDelete())` |
951
+ | 抽屉关掉后整页滚不动 | `overflow:hidden` 只在打开时设,没还原 | `onBeforeUnmount` 还原 |
952
+ | 发第二条时把第一张图又带上 | 附件没在 `submit` 里清空 | 提交后 `attachments = []` |
953
+
954
+ ---
955
+
956
+ ## 11. 验收清单(前端)
957
+
958
+ **模型设置**
959
+
960
+ 1. 首次打开(无 Key)自动停在设置页,悬浮球有红点。
961
+ 2. 点预设 → 地址与模型名自动填好;手改地址后保存,刷新仍在。
962
+ 3. 页面上任何地方都拿不到 Key(DevTools 看 `/api/ai/config` 响应只有 `hasKey`)。
963
+ 4. 保存成功后输入框里的 Key 被清空;「清除 Key」后回到设置页且红点重新出现。
964
+ 5. 服务端返回 400 时,错误文案显示在设置页内,不弹全局 alert。
965
+
966
+ **基本对话**
967
+
968
+ 6. 回答逐字出现,首字前有「思考中…」指示器。
969
+ 7. 点停止 → 立刻停,`status` 回 `ready`,已收到的内容保留。
970
+ 8. 思考模型:思考内容落在折叠块里,正文开始后自动收起并显示「思考了 N 秒」。
971
+ 9. 工具调用:先出现「xx中…」,结果回来后变成「xx」,展开能看参数与结果。
972
+ 10. 报错时正文补一句「(出错了:…)」且输入区下方有红字,发送按钮变重试图标。
973
+
974
+ **消息历史**
975
+
976
+ 11. 聊几句 → 新建会话 → 历史弹层出现上一条,标题是第一句前 24 字。
977
+ 12. 切回旧会话,消息与 part 类型(含工具卡)完整恢复。
978
+ 13. 删除需两次点击;删当前会话后对话区同步清空。
979
+ 14. 刷新页面恢复上次会话;时间显示为「刚刚 / N 分钟前」。
980
+
981
+ **图片**
982
+
983
+ 15. 上传 3 MB 截图 → 缩略图正常,请求体明显变小(DevTools 看 payload)。
984
+ 16. 输入框里 Ctrl+V 粘贴截图 → 变成附件,不插入文本。
985
+ 17. 发第 5 张 → 提示上限 4 张。
986
+ 18. 历史会话里的图片显示占位,且不会发出空 url。
987
+
988
+ **策略与交互**
989
+
990
+ 19. 手动审核:待审卡出现后指示器停止转圈,输入框上方出现「等你审」提示条;批准后自动续跑。
991
+ 20. 驳回后模型换方案继续,文件未被改动(文案明确)。
992
+ 21. 自动审核:没有待审卡,直接显示「已写入并通过校验」。
993
+ 22. 审核策略切换后,图标与下拉里的勾选项一致,刷新后保持。
994
+ 23. 弹出浮层不被抽屉遮挡;抽屉关闭后页面可正常滚动。
995
+
996
+ ---
997
+
998
+ ## 12. 移植到新项目
999
+
1000
+ | 文件 | 保留 | 替换 |
1001
+ |---|---|---|
1002
+ | `composables/use-ai.ts` | 状态机、流式解析、`history()`、会话历史 | 业务 part(如 `review`)可整段删 |
1003
+ | `AiAssistant.vue` | 抽屉骨架、设置面板、输入区、历史弹层 | 空状态示例问题、业务卡片渲染 |
1004
+ | `chat/MessageContent.vue` | part 分发结构 | `TOOL_NAME` 映射、业务 part 分支 |
1005
+ | `chat/ReasoningBlock.vue` / `ChatComark.ts` | 原样搬 | — |
1006
+ | `main.css` 的 `.ai-*` | 抽屉、消息、输入区骨架 | 配色与圆角换成本项目 token |
1007
+
1008
+ 接口侧只需满足第 3 节的契约(config 掩码 + SSE 事件表),前端一行不用改。