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,413 @@
1
+ <template>
2
+ <div class="aicomp" :class="{ 'is-busy': busy, 'is-drag': dragOver, 'is-manual': heightPx > 0, 'is-resizing': resizing }" :style="compStyle">
3
+ <!-- 上缘拖拽把手:调输入区高度(106px ~ 面板高度 50%,双击恢复自适应),与面板宽度把手同一套交互 -->
4
+ <div
5
+ class="aicomp__grip"
6
+ :class="{ 'is-dragging': resizing }"
7
+ title="拖拽调整输入区高度(双击恢复自适应)"
8
+ @pointerdown.prevent="startResize"
9
+ @dblclick.prevent="resetHeight"
10
+ ></div>
11
+
12
+ <!-- 已挂载的本地文件:只预览 + 传路径,不上传 -->
13
+ <div v-if="attachments.length" class="aicomp__files">
14
+ <div
15
+ v-for="att in attachments"
16
+ :key="att.path"
17
+ class="aichip"
18
+ :class="{ 'aichip--dir': att.isDir, 'aichip--pending': att.pending, 'aichip--missing': att.exists === false }"
19
+ :title="att.path"
20
+ >
21
+ <img v-if="att.isImage && !att.pending" class="aichip__thumb" :src="fileUrl(att.path)" :alt="att.name" loading="lazy" />
22
+ <svg v-else class="aichip__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
23
+ <use :href="att.pending ? '#icon-loading' : att.isDir ? '#icon-folder-plus' : att.isImage ? '#icon-image' : '#icon-file-text'" />
24
+ </svg>
25
+ <span class="aichip__name">{{ att.pending ? '解析路径中…' : att.name }}</span>
26
+ <span v-if="!att.pending" class="aichip__sub">{{ chipSub(att) }}</span>
27
+ <button type="button" class="aichip__rm" title="移除" @click="$emit('remove-attachment', att.path)">
28
+ <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-x" /></svg>
29
+ </button>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- 粘贴绝对路径(非 macOS / 拖拽反查失败时的兜底入口) -->
34
+ <div v-if="pathInputOpen" class="aicomp__path">
35
+ <input
36
+ ref="pathEl"
37
+ v-model="pathText"
38
+ class="aicomp__path-input"
39
+ type="text"
40
+ placeholder="粘贴本地图片或文件夹的绝对路径,回车确认"
41
+ spellcheck="false"
42
+ @keydown.enter.prevent="confirmPath"
43
+ @keydown.esc.stop.prevent="closePathInput"
44
+ />
45
+ <button type="button" class="aicomp__path-ok" :disabled="!pathText.trim()" @click="confirmPath">添加</button>
46
+ <button type="button" class="aicomp__path-no" @click="closePathInput">取消</button>
47
+ </div>
48
+
49
+ <div
50
+ class="aicomp__box"
51
+ @dragenter.prevent="onDragEnter"
52
+ @dragover.prevent="onDragOver"
53
+ @dragleave.prevent="onDragLeave"
54
+ @drop.prevent="onDrop"
55
+ >
56
+ <textarea
57
+ ref="taEl"
58
+ v-model="text"
59
+ class="aicomp__ta"
60
+ :placeholder="placeholder"
61
+ rows="1"
62
+ @input="autoGrow"
63
+ @keydown="onKeydown"
64
+ @paste="onPaste"
65
+ ></textarea>
66
+ <div class="aicomp__bar">
67
+ <div class="aicomp__tools">
68
+ <div class="aicomp__attach" ref="attachEl">
69
+ <button type="button" class="aicomp__iconbtn" :class="{ 'is-open': attachOpen }" title="挂载本地图片 / 文件 / 文件夹" @click="toggleAttach">
70
+ <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-paperclip" /></svg>
71
+ </button>
72
+ <div v-if="attachOpen" class="aicomp__menu" role="menu">
73
+ <button type="button" role="menuitem" @click="pick('image')">
74
+ <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-image" /></svg>
75
+ 本地图片…
76
+ </button>
77
+ <button type="button" role="menuitem" @click="pick('file')">
78
+ <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-file-text" /></svg>
79
+ 本地文件…
80
+ </button>
81
+ <button type="button" role="menuitem" @click="pick('folder')">
82
+ <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-folder-plus" /></svg>
83
+ 本地文件夹…
84
+ </button>
85
+ <div class="aicomp__menu-sep"></div>
86
+ <button type="button" role="menuitem" @click="openPathInput">
87
+ <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-link" /></svg>
88
+ 粘贴路径…
89
+ </button>
90
+ </div>
91
+ </div>
92
+ <span class="aicomp__hint">拖拽文件 / 文件夹到此,或直接 ⌘V 粘贴截图</span>
93
+ </div>
94
+ <div class="aicomp__right">
95
+ <span v-if="ctx" class="aicomp__ctx" :class="`is-${ctx.level}`" :title="ctxTitle">
96
+ <svg class="aicomp__ctx-ring" viewBox="0 0 20 20" aria-hidden="true">
97
+ <circle class="aicomp__ctx-track" cx="10" cy="10" r="8" />
98
+ <circle
99
+ class="aicomp__ctx-arc"
100
+ cx="10"
101
+ cy="10"
102
+ r="8"
103
+ :stroke-dasharray="`${ctx.arc} ${ctx.circ}`"
104
+ transform="rotate(-90 10 10)"
105
+ />
106
+ </svg>
107
+ <span class="aicomp__ctx-pct">{{ ctx.pctLabel }}</span>
108
+ </span>
109
+ <span v-if="model" class="aicomp__model" :title="`当前模型:${model}`">{{ model }}</span>
110
+ <button
111
+ v-if="busy"
112
+ type="button"
113
+ class="aicomp__send aicomp__send--stop"
114
+ title="终止生成(Esc)"
115
+ @click="$emit('stop')"
116
+ >
117
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><use href="#icon-stop" /></svg>
118
+ </button>
119
+ <button
120
+ v-else
121
+ type="button"
122
+ class="aicomp__send"
123
+ :disabled="!canSend"
124
+ :title="canSend ? '发送(Enter)' : '输入内容后发送'"
125
+ @click="submit"
126
+ >
127
+ <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-send" /></svg>
128
+ </button>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </template>
134
+
135
+ <script>
136
+ import { aiFileUrl } from '../runtime/ai-api.js'
137
+
138
+ /** 输入区手动高度下限(px):两行正文 + 工具条的最小可用高度 */
139
+ const MIN_HEIGHT = 106
140
+ /** 输入区手动高度上限 = 对话面板高度的比例 */
141
+ const MAX_RATIO = 0.5
142
+ const HEIGHT_KEY = 'cardbuddy:ai-composer-height'
143
+
144
+ export default {
145
+ name: 'ChatComposer',
146
+ props: {
147
+ /** 生成中:发送键切成终止键,Enter 不再触发发送 */
148
+ busy: { type: Boolean, default: false },
149
+ /** 当前模型名(状态展示) */
150
+ model: { type: String, default: '' },
151
+ /** 已挂载的本地文件 / 文件夹 */
152
+ attachments: { type: Array, default: () => [] },
153
+ /** 未配置模型时的占位提示 */
154
+ placeholder: { type: String, default: '描述你要改的卡片,Enter 发送 / Shift+Enter 换行' },
155
+ /** 上下文占用(后端每轮 SSE context-usage 回传):{ used, total, window },null = 尚未跑过 */
156
+ context: { type: Object, default: null },
157
+ },
158
+ emits: ['send', 'stop', 'pick', 'add-path', 'remove-attachment', 'drop-files', 'paste-image', 'escape'],
159
+ data() {
160
+ return {
161
+ text: '',
162
+ attachOpen: false,
163
+ pathInputOpen: false,
164
+ pathText: '',
165
+ dragOver: false,
166
+ /** 输入区手动高度(px,0 = 自适应):拖拽把手设定,localStorage 持久化 */
167
+ heightPx: 0,
168
+ resizing: false,
169
+ _outside: null,
170
+ _onWinResize: null,
171
+ }
172
+ },
173
+ computed: {
174
+ canSend() {
175
+ return Boolean(this.text.trim() || this.attachments.some((a) => !a.pending && a.exists !== false))
176
+ },
177
+ /** 手动高度生效时才写内联高度:自适应态完全交给内容撑开 */
178
+ compStyle() {
179
+ return this.heightPx > 0 ? { height: `${this.heightPx}px` } : null
180
+ },
181
+ /** 上下文占比圆环的几何量:环周长固定,弧长 = 周长 × 占比 */
182
+ ctx() {
183
+ const c = this.context
184
+ if (!c) return null
185
+ const total = Number(c.window) > 0 ? Number(c.window) : Number(c.total) > 0 ? Number(c.total) : 0
186
+ const used = Math.max(0, Number(c.used) || 0)
187
+ if (!(total > 0)) return null
188
+ const pct = Math.min(1, used / total)
189
+ const circ = 2 * Math.PI * 8
190
+ return {
191
+ pct,
192
+ arc: (circ * pct).toFixed(2),
193
+ circ: circ.toFixed(2),
194
+ pctLabel: `${Math.round(pct * 100)}%`,
195
+ level: pct >= 0.85 ? 'danger' : pct >= 0.6 ? 'warn' : 'ok',
196
+ used,
197
+ total,
198
+ }
199
+ },
200
+ ctxTitle() {
201
+ const c = this.ctx
202
+ if (!c) return ''
203
+ const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)))
204
+ const win = Number(this.context?.window) > 0 ? ` · 网关窗口 ${fmt(Number(this.context.window))}` : ''
205
+ return `上下文占用 ${fmt(c.used)} / ${fmt(c.total)} token(${c.pctLabel})${win};接近上限时后端会自动折叠早期工具结果`
206
+ },
207
+ },
208
+ mounted() {
209
+ this._outside = (ev) => {
210
+ const host = this.$refs.attachEl
211
+ if (host && !host.contains(ev.target)) this.attachOpen = false
212
+ }
213
+ document.addEventListener('mousedown', this._outside)
214
+ this.loadHeight()
215
+ // 面板 / 窗口变矮时手动高度可能超过 50% 上限:实时夹回,避免输入区吃掉半个对话面板
216
+ this._onWinResize = () => this.clampHeight()
217
+ window.addEventListener('resize', this._onWinResize)
218
+ this.autoGrow()
219
+ },
220
+ beforeUnmount() {
221
+ if (this._outside) document.removeEventListener('mousedown', this._outside)
222
+ if (this._onWinResize) window.removeEventListener('resize', this._onWinResize)
223
+ },
224
+ methods: {
225
+ focus() {
226
+ this.$nextTick(() => this.$refs.taEl?.focus())
227
+ },
228
+ /** 供面板「建议问法」等外部填充输入框 */
229
+ setText(value) {
230
+ this.text = value
231
+ this.focus()
232
+ this.autoGrow()
233
+ },
234
+ fileUrl(p) {
235
+ return aiFileUrl(p)
236
+ },
237
+ chipSub(att) {
238
+ if (att.exists === false) return '不可读'
239
+ if (att.isDir) return `${att.entryCount ?? att.entries?.length ?? 0} 项`
240
+ const n = Number(att.size || 0)
241
+ if (!n) return ''
242
+ if (n < 1024) return `${n} B`
243
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
244
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
245
+ },
246
+ autoGrow() {
247
+ const el = this.$refs.taEl
248
+ if (!el) return
249
+ // 手动高度态:高度由容器决定(textarea 撑满剩余空间),内容超出自己滚动
250
+ if (this.heightPx > 0) {
251
+ el.style.height = ''
252
+ return
253
+ }
254
+ el.style.height = 'auto'
255
+ el.style.height = `${Math.min(el.scrollHeight, 168)}px`
256
+ },
257
+ // ---------- 输入区高度(与面板宽度把手同一套:pointer 捕获 + body 光标 + localStorage)----------
258
+ /** 上限 = 对话面板高度的 50%;下限 106px(够放两行正文 + 工具条) */
259
+ heightLimits() {
260
+ const panel = this.$el?.closest('.aichat')
261
+ const panelH = panel?.clientHeight || window.innerHeight
262
+ return { min: MIN_HEIGHT, max: Math.max(MIN_HEIGHT, Math.floor(panelH * MAX_RATIO)) }
263
+ },
264
+ clampHeight() {
265
+ if (!this.heightPx) return
266
+ const { min, max } = this.heightLimits()
267
+ this.heightPx = Math.max(min, Math.min(max, this.heightPx))
268
+ },
269
+ loadHeight() {
270
+ try {
271
+ const v = localStorage.getItem(HEIGHT_KEY)
272
+ const px = v == null ? 0 : parseInt(v, 10)
273
+ if (!Number.isNaN(px) && px >= MIN_HEIGHT) this.heightPx = px
274
+ } catch {
275
+ /* localStorage 不可用时保持自适应 */
276
+ }
277
+ this.clampHeight()
278
+ },
279
+ saveHeight() {
280
+ try {
281
+ if (this.heightPx) localStorage.setItem(HEIGHT_KEY, String(Math.round(this.heightPx)))
282
+ else localStorage.removeItem(HEIGHT_KEY)
283
+ } catch {
284
+ /* 忽略 */
285
+ }
286
+ },
287
+ startResize(ev) {
288
+ const el = ev && ev.currentTarget
289
+ if (!el) return
290
+ this.resizing = true
291
+ document.body.style.cursor = 'row-resize'
292
+ document.body.style.userSelect = 'none'
293
+ const pointerId = ev.pointerId
294
+ try {
295
+ el.setPointerCapture(pointerId)
296
+ } catch {
297
+ /* 不支持捕获时退化为元素级监听 */
298
+ }
299
+ // 输入区贴面板底边:新高度 = 底边 - 指针 y(往上拖变高)
300
+ const bottom = this.$el.getBoundingClientRect().bottom
301
+ const move = (e) => {
302
+ const { min, max } = this.heightLimits()
303
+ this.heightPx = Math.max(min, Math.min(max, Math.round(bottom - e.clientY)))
304
+ }
305
+ const finish = () => {
306
+ el.removeEventListener('pointermove', move)
307
+ el.removeEventListener('pointerup', finish)
308
+ el.removeEventListener('pointercancel', finish)
309
+ window.removeEventListener('blur', finish)
310
+ try {
311
+ if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
312
+ } catch {
313
+ /* 已释放 */
314
+ }
315
+ this.resizing = false
316
+ document.body.style.cursor = ''
317
+ document.body.style.userSelect = ''
318
+ this.saveHeight()
319
+ }
320
+ el.addEventListener('pointermove', move)
321
+ el.addEventListener('pointerup', finish)
322
+ el.addEventListener('pointercancel', finish)
323
+ window.addEventListener('blur', finish) // 失焦兜底,避免拖拽态卡死
324
+ },
325
+ /** 双击把手:回到内容自适应(清持久化) */
326
+ resetHeight() {
327
+ this.heightPx = 0
328
+ this.saveHeight()
329
+ this.$nextTick(this.autoGrow)
330
+ },
331
+ onKeydown(ev) {
332
+ // Esc:生成中 → 交给面板终止;否则关面板(与 Codex 一致的中断手感)
333
+ if (ev.key === 'Escape') {
334
+ ev.preventDefault()
335
+ this.$emit('escape')
336
+ return
337
+ }
338
+ if (ev.key !== 'Enter') return
339
+ // 输入法组词中的 Enter 是「选词」,不能当发送
340
+ if (ev.isComposing || ev.keyCode === 229) return
341
+ if (ev.shiftKey) return // Shift+Enter 换行
342
+ ev.preventDefault()
343
+ if (!this.busy) this.submit()
344
+ },
345
+ submit() {
346
+ if (this.busy || !this.canSend) return
347
+ const payload = { text: this.text.trim(), attachments: this.attachments.filter((a) => !a.pending && a.exists !== false) }
348
+ this.text = ''
349
+ this.attachOpen = false
350
+ this.$nextTick(this.autoGrow)
351
+ this.$emit('send', payload)
352
+ },
353
+ toggleAttach() {
354
+ this.attachOpen = !this.attachOpen
355
+ },
356
+ pick(kind) {
357
+ this.attachOpen = false
358
+ this.$emit('pick', kind)
359
+ },
360
+ openPathInput() {
361
+ this.attachOpen = false
362
+ this.pathInputOpen = true
363
+ this.pathText = ''
364
+ this.$nextTick(() => this.$refs.pathEl?.focus())
365
+ },
366
+ closePathInput() {
367
+ this.pathInputOpen = false
368
+ this.pathText = ''
369
+ },
370
+ confirmPath() {
371
+ const value = this.pathText.trim()
372
+ if (!value) return
373
+ this.$emit('add-path', value)
374
+ this.closePathInput()
375
+ },
376
+ onDragEnter() {
377
+ this.dragOver = true
378
+ },
379
+ onDragOver() {
380
+ this.dragOver = true
381
+ },
382
+ onDragLeave() {
383
+ this.dragOver = false
384
+ },
385
+ onDrop(ev) {
386
+ this.dragOver = false
387
+ const dt = ev.dataTransfer
388
+ if (!dt) return
389
+ this.$emit('drop-files', dt)
390
+ },
391
+ /** ⌘V 粘贴截图:交给面板落盘成本机文件(不上传远端) */
392
+ async onPaste(ev) {
393
+ const items = ev.clipboardData?.items
394
+ if (!items) return
395
+ for (const item of items) {
396
+ if (item.type && item.type.indexOf('image/') === 0) {
397
+ const file = item.getAsFile()
398
+ if (!file) continue
399
+ ev.preventDefault()
400
+ const dataUrl = await new Promise((resolve) => {
401
+ const fr = new FileReader()
402
+ fr.onload = () => resolve(String(fr.result || ''))
403
+ fr.onerror = () => resolve('')
404
+ fr.readAsDataURL(file)
405
+ })
406
+ if (dataUrl) this.$emit('paste-image', dataUrl)
407
+ return
408
+ }
409
+ }
410
+ },
411
+ },
412
+ }
413
+ </script>