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.
- package/README.md +4 -4
- package/dist/cli.js +14 -6
- package/dist/config.js +13 -2
- package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
- package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/server-core/_vendor/finmall-env.mjs +4 -4
- package/dist/server-core/ai-chat.js +3710 -0
- package/dist/server-core/cardFiles.js +95 -2
- package/dist/server-core/cardImage.js +14 -0
- package/dist/server-core/cardSave.js +2 -0
- package/dist/server-core/cardValidate.js +3 -0
- package/dist/server-core/index.js +3 -0
- package/dist/server-core/sliceUpload.js +8 -1
- package/dist/vendored/App.vue +269 -79
- package/dist/vendored/api.js +4 -4
- package/dist/vendored/assets/svgIcons/icons.svg +28 -0
- package/dist/vendored/components/AiSettings.vue +392 -0
- package/dist/vendored/components/CardGallery.vue +51 -5
- package/dist/vendored/components/ChatComposer.vue +413 -0
- package/dist/vendored/components/ChatMessage.vue +383 -0
- package/dist/vendored/components/ChatPanel.vue +937 -0
- package/dist/vendored/components/DebugPanel.vue +30 -33
- package/dist/vendored/components/Dropdown.vue +4 -2
- package/dist/vendored/components/MarkdownBody.vue +64 -0
- package/dist/vendored/components/NewCardDialog.vue +132 -0
- package/dist/vendored/components/RemotePreview.vue +1 -1
- package/dist/vendored/main.js +1 -0
- package/dist/vendored/runtime/adapter.js +1 -1
- package/dist/vendored/runtime/ai-api.js +165 -0
- package/dist/vendored/runtime/markdown.js +140 -0
- package/dist/vendored/skills/.manifest.json +1101 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
- package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
- package/dist/vendored/styles/ai-chat.css +1850 -0
- package/dist/vendored/styles/app.css +107 -3
- package/dist/vendored/styles/card-gallery.css +45 -7
- package/dist/vendored/styles/node-inspector.css +11 -0
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-C_UKS0NF.css +0 -1
- 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>
|