cardbuddy 0.10.0 → 0.11.0

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 (47) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.js +14 -6
  3. package/dist/config.js +13 -2
  4. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  5. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  6. package/dist/preview-editor/index.html +2 -2
  7. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  8. package/dist/server-core/ai-chat.js +3710 -0
  9. package/dist/server-core/cardFiles.js +95 -2
  10. package/dist/server-core/cardImage.js +14 -0
  11. package/dist/server-core/cardSave.js +2 -0
  12. package/dist/server-core/cardValidate.js +3 -0
  13. package/dist/server-core/index.js +3 -0
  14. package/dist/server-core/sliceUpload.js +8 -1
  15. package/dist/vendored/App.vue +269 -79
  16. package/dist/vendored/api.js +4 -4
  17. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  18. package/dist/vendored/components/AiSettings.vue +392 -0
  19. package/dist/vendored/components/CardGallery.vue +51 -5
  20. package/dist/vendored/components/ChatComposer.vue +413 -0
  21. package/dist/vendored/components/ChatMessage.vue +383 -0
  22. package/dist/vendored/components/ChatPanel.vue +937 -0
  23. package/dist/vendored/components/DebugPanel.vue +30 -33
  24. package/dist/vendored/components/Dropdown.vue +4 -2
  25. package/dist/vendored/components/MarkdownBody.vue +64 -0
  26. package/dist/vendored/components/NewCardDialog.vue +132 -0
  27. package/dist/vendored/components/RemotePreview.vue +1 -1
  28. package/dist/vendored/main.js +1 -0
  29. package/dist/vendored/runtime/adapter.js +1 -1
  30. package/dist/vendored/runtime/ai-api.js +165 -0
  31. package/dist/vendored/runtime/markdown.js +140 -0
  32. package/dist/vendored/skills/.manifest.json +1101 -0
  33. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  34. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  35. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  36. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  37. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  38. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  39. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  40. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  41. package/dist/vendored/styles/ai-chat.css +1850 -0
  42. package/dist/vendored/styles/app.css +107 -3
  43. package/dist/vendored/styles/card-gallery.css +45 -7
  44. package/dist/vendored/styles/node-inspector.css +11 -0
  45. package/package.json +1 -1
  46. package/dist/preview-editor/assets/index-C_UKS0NF.css +0 -1
  47. package/dist/preview-editor/assets/index-D1iIJ4sl.js +0 -81
@@ -0,0 +1,383 @@
1
+ <template>
2
+ <article class="aimsg" :class="[`aimsg--${msg.role}`, { 'aimsg--live': streaming }]">
3
+ <!-- ===== 用户消息:附件芯片 + 文本气泡 ===== -->
4
+ <template v-if="msg.role === 'user'">
5
+ <div v-if="attachments.length" class="aimsg__files">
6
+ <div
7
+ v-for="att in attachments"
8
+ :key="att.path"
9
+ class="aifile"
10
+ :class="{ 'aifile--dir': att.isDir, 'aifile--missing': att.exists === false }"
11
+ :title="att.path"
12
+ >
13
+ <img
14
+ v-if="att.isImage"
15
+ class="aifile__thumb"
16
+ :src="fileUrl(att.path)"
17
+ :alt="att.name"
18
+ loading="lazy"
19
+ />
20
+ <svg v-else class="aifile__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
21
+ <use :href="att.isDir ? '#icon-folder-plus' : '#icon-file-text'" />
22
+ </svg>
23
+ <span class="aifile__meta">
24
+ <span class="aifile__name">{{ att.name }}</span>
25
+ <span class="aifile__sub">{{ attSub(att) }}</span>
26
+ </span>
27
+ </div>
28
+ </div>
29
+ <div v-if="msg.content" class="aimsg__bubble">{{ msg.content }}</div>
30
+ </template>
31
+
32
+ <!-- ===== 助手消息:思考 → 工具 → 正文(与 Codex 同序) ===== -->
33
+ <template v-else>
34
+ <!-- 思考过程:默认折叠成一行,进行中给「计时 + 最新一行」预览,结束收成「已思考 N 秒」 -->
35
+ <div v-if="msg.reasoning" class="aithink" :class="{ 'is-open': thinkOpen, 'is-live': thinking }">
36
+ <button
37
+ type="button"
38
+ class="aithink__head"
39
+ :aria-expanded="thinkOpen ? 'true' : 'false'"
40
+ @click="thinkOpen = !thinkOpen"
41
+ >
42
+ <svg class="aithink__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-chevron-right" /></svg>
43
+ <svg v-if="thinking" class="aithink__spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-loading" /></svg>
44
+ <svg v-else class="aithink__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-sparkles" /></svg>
45
+ <span class="aithink__label">{{ thinkLabel }}</span>
46
+ <span v-if="thinking && !thinkOpen" class="aithink__peek">{{ peek }}</span>
47
+ </button>
48
+ <div v-show="thinkOpen" class="aithink__body">
49
+ <pre ref="thinkPre">{{ msg.reasoning }}</pre>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- 首个 token 之前的等待态 -->
54
+ <div v-if="waiting" class="aiwait">
55
+ <span class="aiwait__dot"></span><span class="aiwait__dot"></span><span class="aiwait__dot"></span>
56
+ <span class="aiwait__text">正在思考</span>
57
+ </div>
58
+
59
+ <!-- 工具调用:一行摘要,可展开看参数 / 结果 / 截图 -->
60
+ <div
61
+ v-for="tc in toolCalls"
62
+ :key="tc.toolCallId"
63
+ class="aitool"
64
+ :class="[`aitool--${toolState(tc)}`, { 'is-open': isOpen(tc) }]"
65
+ >
66
+ <button type="button" class="aitool__head" :aria-expanded="isOpen(tc) ? 'true' : 'false'" @click="toggleTool(tc)">
67
+ <svg class="aitool__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-chevron-right" /></svg>
68
+ <svg v-if="toolState(tc) === 'running' || toolState(tc) === 'pending'" class="aitool__spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-loading" /></svg>
69
+ <svg v-else-if="toolState(tc) === 'error'" class="aitool__status" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-alert-triangle" /></svg>
70
+ <svg v-else class="aitool__status" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-check" /></svg>
71
+ <span class="aitool__name">{{ toolLabel(tc.toolName) }}</span>
72
+ <code class="aitool__args">{{ tc.pending ? '参数生成中…' : argSummary(tc) }}</code>
73
+ <span v-if="toolMs(tc)" class="aitool__ms">{{ toolMs(tc) }}</span>
74
+ </button>
75
+ <!-- 高危执行逐次确认:命令原文 + 允许一次 / 本回合不再询问 / 拒绝(Codex 审批的同款交互) -->
76
+ <div v-if="tc.needsApproval" class="aitool__approve">
77
+ <svg class="aitool__approve-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-alert-triangle" /></svg>
78
+ <code class="aitool__approve-cmd" :title="approvalCommand(tc)">{{ approvalCommand(tc) }}</code>
79
+ <span class="aitool__approve-hint">{{ tc.toolName === 'skill_exec' ? '技能脚本,需要你确认' : '本地命令,需要你确认' }}</span>
80
+ <button type="button" class="aitool__approve-btn" @click.stop="$emit('approval', { tc, ok: true, scope: 'once' })">允许一次</button>
81
+ <button type="button" class="aitool__approve-btn aitool__approve-btn--ghost" @click.stop="$emit('approval', { tc, ok: true, scope: 'turn' })">本回合不再询问</button>
82
+ <button type="button" class="aitool__approve-btn aitool__approve-btn--deny" @click.stop="$emit('approval', { tc, ok: false })">拒绝</button>
83
+ </div>
84
+ <!-- 免确认策略命中:没有审批条,但照样把「跑了什么命令」摆出来,免确认不等于无痕迹 -->
85
+ <div v-else-if="tc.approvalNote" class="aitool__approve-note" :class="{ 'is-auto': tc.autoApproved }">
86
+ <span class="aitool__approve-hint">{{ tc.approvalNote }}</span>
87
+ <code v-if="tc.autoApproved && tc.command" class="aitool__approve-cmd" :title="tc.command">{{ tc.command }}</code>
88
+ </div>
89
+ <div v-show="isOpen(tc)" class="aitool__body">
90
+ <MarkdownBody v-if="argsSource(tc)" :source="argsSource(tc)" />
91
+ <img
92
+ v-if="tc.image"
93
+ class="aitool__shot"
94
+ :src="`data:${tc.image.mime || 'image/png'};base64,${tc.image.base64}`"
95
+ alt="卡片截图"
96
+ />
97
+ <div v-if="tc.error" class="aitool__error">{{ tc.error }}</div>
98
+ <MarkdownBody v-else-if="resultSource(tc)" :source="resultSource(tc)" />
99
+ </div>
100
+ </div>
101
+
102
+ <!-- 正文:Markdown(流式重渲染 + 代码块高亮 / 复制) -->
103
+ <MarkdownBody
104
+ v-if="msg.content"
105
+ class="aimsg__md"
106
+ :class="{ 'is-streaming': streamingText }"
107
+ :source="msg.content"
108
+ :streaming="streamingText"
109
+ />
110
+
111
+ <div v-if="msg.error" class="aimsg__error">
112
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-alert-triangle" /></svg>
113
+ <span>{{ msg.error }}</span>
114
+ </div>
115
+ <div v-if="msg.status === 'aborted'" class="aimsg__aborted">已终止生成</div>
116
+ <div v-else-if="msg.budget?.message" class="aimsg__budget" :title="budgetHint">
117
+ <svg class="aimsg__budget-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-alert-triangle" /></svg>
118
+ <span class="aimsg__budget-text"><strong>{{ budgetLabel }}</strong>:{{ msg.budget.message }}(上下文已保留)</span>
119
+ <button v-if="canRegenerate && !streaming" type="button" class="aimsg__budget-go" @click="$emit('continue')">继续这轮</button>
120
+ </div>
121
+
122
+ <!-- 悬停操作:复制 / 重新生成(仅最后一条助手消息可重发) -->
123
+ <footer v-if="msg.content || msg.error" class="aimsg__acts">
124
+ <button type="button" class="aimsg__act" :title="copied ? '已复制' : '复制回答'" @click="copyText">
125
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use :href="copied ? '#icon-check' : '#icon-copy-sm'" /></svg>
126
+ <span>{{ copied ? '已复制' : '复制' }}</span>
127
+ </button>
128
+ <button v-if="canRegenerate && !streaming" type="button" class="aimsg__act" title="丢弃这次回答并重新生成" @click="$emit('regenerate', msg)">
129
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-reset" /></svg>
130
+ <span>重新生成</span>
131
+ </button>
132
+ </footer>
133
+ </template>
134
+ </article>
135
+ </template>
136
+
137
+ <script>
138
+ import MarkdownBody from './MarkdownBody.vue'
139
+ import { aiFileUrl } from '../runtime/ai-api.js'
140
+ import { copyText as writeClipboard } from '../runtime/clipboard.js'
141
+
142
+ /** 工具名 → 中文动作标签(与 mcp/tools.mjs 的工具集对齐) */
143
+ const TOOL_LABELS = {
144
+ card_list: '列出卡片',
145
+ card_read: '读取卡片',
146
+ card_write: '写入卡片',
147
+ card_validate: '校验卡片',
148
+ card_render_status: '渲染状态',
149
+ card_node_models: '组件模型',
150
+ card_screenshot: '截图预览',
151
+ card_sync: '同步远端卡片',
152
+ card_save: '推送到远端',
153
+ card_slice_upload: '上传切图',
154
+ card_import_meta: '来源侧车',
155
+ card_route_input: '输入路由',
156
+ card_scaffold: '生成骨架',
157
+ card_add_component: '添加组件',
158
+ card_history: '编辑历史',
159
+ card_rollback: '回滚版本',
160
+ skill_read: '读技能文档',
161
+ skill_exec: '跑技能脚本',
162
+ card_grep: '定位卡片字段',
163
+ card_patch: '最小改写卡片',
164
+ fs_read: '读文件',
165
+ fs_list: '列目录',
166
+ shell_exec: '本地命令',
167
+ }
168
+
169
+ export default {
170
+ name: 'ChatMessage',
171
+ components: { MarkdownBody },
172
+ props: {
173
+ /** 消息对象:{ id, role, content, reasoning, reasoningMs, toolCalls, attachments, error, status, budget, ts } */
174
+ msg: { type: Object, required: true },
175
+ /** 本条消息是否仍在流式输出 */
176
+ streaming: { type: Boolean, default: false },
177
+ /** 是否允许「重新生成」(仅最后一条助手消息) */
178
+ canRegenerate: { type: Boolean, default: false },
179
+ },
180
+ emits: ['regenerate', 'approval', 'continue'],
181
+ data() {
182
+ return {
183
+ thinkOpen: false,
184
+ copied: false,
185
+ elapsed: 0,
186
+ openTools: Object.create(null),
187
+ _tick: 0,
188
+ }
189
+ },
190
+ computed: {
191
+ /** 收尾原因的短标签(消息里已带完整人话说明,这里只给分类) */
192
+ budgetLabel() {
193
+ const map = { loop: '死循环熔断', timeout: '墙钟熔断', steps: '步数硬顶' }
194
+ return map[this.msg.budget?.reason] || '本轮收尾'
195
+ },
196
+ /** 悬停给出「怎么办」:三种收尾的下一步动作不同 */
197
+ budgetHint() {
198
+ const map = {
199
+ loop: '连续多轮同工具同参数 = 拿不到新信息。回复「继续」前最好补一句新思路(换证据链 / 指定要改的字段),否则大概率原地再转一次。',
200
+ timeout: '本轮跑到墙钟上限(AI_TURN_TIMEOUT_MS 可调)。回复「继续」会带着完整上下文从中断处接着做。',
201
+ steps: '你在设置里配了「单轮步数上限」。留空即不限步数,失控由死循环 / 墙钟熔断兜住。',
202
+ }
203
+ return map[this.msg.budget?.reason] || '上下文已保留,回复「继续」可接着做。'
204
+ },
205
+ attachments() {
206
+ return Array.isArray(this.msg.attachments) ? this.msg.attachments : []
207
+ },
208
+ toolCalls() {
209
+ return Array.isArray(this.msg.toolCalls) ? this.msg.toolCalls : []
210
+ },
211
+ /** 思考中:还在流式、且尚未产出正文 / 工具调用 */
212
+ thinking() {
213
+ return Boolean(this.streaming && !this.msg.content && !this.toolCalls.length)
214
+ },
215
+ /** 正文流式中(思考已结束) */
216
+ streamingText() {
217
+ return Boolean(this.streaming && this.msg.content)
218
+ },
219
+ /** 首 token 之前 */
220
+ waiting() {
221
+ return Boolean(this.streaming && !this.msg.reasoning && !this.msg.content && !this.toolCalls.length && !this.msg.error)
222
+ },
223
+ thinkLabel() {
224
+ if (this.thinking) return `思考中… ${this.fmtSeconds(this.elapsedMs)}`
225
+ return `已思考 ${this.fmtSeconds(this.msg.reasoningMs || this.elapsedMs)}`
226
+ },
227
+ elapsedMs() {
228
+ return this.thinking ? this.elapsed : this.msg.reasoningMs || this.elapsed
229
+ },
230
+ /** 折叠态下滚动展示「最新一行」思考内容,形成 Codex 式的过程感 */
231
+ peek() {
232
+ const lines = String(this.msg.reasoning || '').split('\n').map((l) => l.trim()).filter(Boolean)
233
+ const last = lines[lines.length - 1] || ''
234
+ return last.length > 72 ? `${last.slice(0, 72)}…` : last
235
+ },
236
+ },
237
+ watch: {
238
+ streaming: {
239
+ immediate: true,
240
+ handler(val) {
241
+ if (val) this.startTick()
242
+ else this.stopTick()
243
+ },
244
+ },
245
+ // 思考一结束(出正文或工具行)计时器就没用了,停掉以免整轮都在 250ms 空转重渲染
246
+ thinking(val) {
247
+ if (!val) this.stopTick()
248
+ },
249
+ // 工具报错时自动展开,便于立刻看到失败原因
250
+ 'msg.toolCalls': {
251
+ deep: true,
252
+ handler(list) {
253
+ for (const tc of list || []) {
254
+ if (tc.error && this.openTools[tc.toolCallId] === undefined) {
255
+ this.openTools = { ...this.openTools, [tc.toolCallId]: true }
256
+ }
257
+ }
258
+ },
259
+ },
260
+ },
261
+ beforeUnmount() {
262
+ this.stopTick()
263
+ },
264
+ updated() {
265
+ // 展开且仍在思考时,思考正文自动跟随滚动到底
266
+ const pre = this.$refs.thinkPre
267
+ if (pre && this.thinkOpen && this.thinking) pre.scrollTop = pre.scrollHeight
268
+ },
269
+ methods: {
270
+ startTick() {
271
+ this.stopTick()
272
+ this.elapsed = 0
273
+ this._tick = setInterval(() => {
274
+ this.elapsed += 250
275
+ }, 250)
276
+ },
277
+ stopTick() {
278
+ if (this._tick) clearInterval(this._tick)
279
+ this._tick = 0
280
+ },
281
+ fmtSeconds(ms) {
282
+ const sec = Math.max(0, Math.round(Number(ms || 0) / 1000))
283
+ if (sec < 60) return `${sec} 秒`
284
+ return `${Math.floor(sec / 60)} 分 ${sec % 60} 秒`
285
+ },
286
+ fileUrl(p) {
287
+ return aiFileUrl(p)
288
+ },
289
+ attSub(att) {
290
+ if (att.exists === false) return '路径不可读'
291
+ if (att.isDir) return `${att.entryCount ?? att.entries?.length ?? 0} 项`
292
+ return this.formatSize(att.size)
293
+ },
294
+ formatSize(bytes) {
295
+ const n = Number(bytes || 0)
296
+ if (!n) return '0 B'
297
+ if (n < 1024) return `${n} B`
298
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
299
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
300
+ },
301
+ toolLabel(name) {
302
+ return TOOL_LABELS[name] || name || '工具'
303
+ },
304
+ toolState(tc) {
305
+ if (tc.error) return 'error'
306
+ if (tc.result !== undefined && tc.result !== null) return 'done'
307
+ // 工具名已到、参数仍在流式拼装(后端 tool-call-start)
308
+ if (tc.pending) return 'pending'
309
+ return 'running'
310
+ },
311
+ toolMs(tc) {
312
+ if (!tc.ms) return ''
313
+ return tc.ms < 1000 ? `${tc.ms}ms` : `${(tc.ms / 1000).toFixed(1)}s`
314
+ },
315
+ isOpen(tc) {
316
+ const v = this.openTools[tc.toolCallId]
317
+ return v === undefined ? Boolean(tc.error) : v
318
+ },
319
+ toggleTool(tc) {
320
+ this.openTools = { ...this.openTools, [tc.toolCallId]: !this.isOpen(tc) }
321
+ },
322
+ /** 审批条上的命令原文:后端给 command 就用它,否则按工具参数还原一行 */
323
+ approvalCommand(tc) {
324
+ if (tc.command) return String(tc.command)
325
+ const args = tc.args || {}
326
+ if (typeof args.command === 'string' && args.command) return args.command
327
+ if (tc.toolName === 'skill_exec') return [args.script, Array.isArray(args.args) ? args.args.join(' ') : ''].filter(Boolean).join(' ')
328
+ return JSON.stringify(args)
329
+ },
330
+ /** 参数摘要:挑关键字段拼一行,长文本截断 */
331
+ argSummary(tc) {
332
+ // 技能脚本按「命令行」展示:script + args 数组拼起来才是人能读懂的一行
333
+ if (tc.toolName === 'skill_exec' && tc.args?.script) {
334
+ const argv = Array.isArray(tc.args.args) ? tc.args.args.join(' ') : ''
335
+ const cmd = [String(tc.args.script).replace(/^skills\//, ''), argv].filter(Boolean).join(' ')
336
+ return cmd.length > 96 ? `${cmd.slice(0, 96)}…` : cmd
337
+ }
338
+ const args = tc.args || {}
339
+ const parts = []
340
+ for (const [k, v] of Object.entries(args)) {
341
+ if (v === undefined || v === null || v === '') continue
342
+ const text = typeof v === 'object' ? JSON.stringify(v) : String(v)
343
+ parts.push(`${k}=${text.length > 28 ? `${text.slice(0, 28)}…` : text}`)
344
+ if (parts.join(' ').length > 56) break
345
+ }
346
+ const out = parts.join(' ')
347
+ return out.length > 72 ? `${out.slice(0, 72)}…` : out
348
+ },
349
+ argsSource(tc) {
350
+ const args = tc.args || {}
351
+ if (!Object.keys(args).length) return ''
352
+ let text = ''
353
+ try {
354
+ text = JSON.stringify(args, null, 2)
355
+ } catch {
356
+ text = String(args)
357
+ }
358
+ return `\`\`\`json\n${text}\n\`\`\``
359
+ },
360
+ resultSource(tc) {
361
+ const raw = tc.result
362
+ if (raw === undefined || raw === null || raw === '') return ''
363
+ let text = typeof raw === 'string' ? raw : JSON.stringify(raw)
364
+ try {
365
+ text = JSON.stringify(JSON.parse(text), null, 2)
366
+ } catch {
367
+ /* 非 JSON 结果按纯文本展示 */
368
+ }
369
+ if (text.length > 4000) text = `${text.slice(0, 4000)}\n…(已截断)`
370
+ return `\`\`\`json\n${text}\n\`\`\``
371
+ },
372
+ async copyText() {
373
+ // 与代码块复制钮同源:clipboard.js 带 execCommand 降级,非安全上下文也能复制
374
+ const ok = await writeClipboard(String(this.msg.content || ''))
375
+ if (!ok) return
376
+ this.copied = true
377
+ setTimeout(() => {
378
+ this.copied = false
379
+ }, 1600)
380
+ },
381
+ },
382
+ }
383
+ </script>