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,937 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<aside
|
|
3
|
+
class="aichat"
|
|
4
|
+
:class="{ 'is-open': visible }"
|
|
5
|
+
:style="{ width: panelWidth + 'px' }"
|
|
6
|
+
role="complementary"
|
|
7
|
+
aria-label="AI 开发助手"
|
|
8
|
+
@keydown.esc="onEscape"
|
|
9
|
+
>
|
|
10
|
+
<!-- 左缘拖拽把手:调宽(双击恢复默认 33%) -->
|
|
11
|
+
<div
|
|
12
|
+
class="aichat__grip"
|
|
13
|
+
:class="{ 'is-dragging': resizing }"
|
|
14
|
+
title="拖拽调整宽度(双击恢复默认)"
|
|
15
|
+
@pointerdown.prevent="startResize"
|
|
16
|
+
@dblclick.prevent="resetWidth"
|
|
17
|
+
></div>
|
|
18
|
+
|
|
19
|
+
<!-- 头部:标题 + 当前卡片 / 模型 + 清空 / 设置 / 关闭 -->
|
|
20
|
+
<header class="aichat__head">
|
|
21
|
+
<span class="aichat__logo">
|
|
22
|
+
<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-sparkles" /></svg>
|
|
23
|
+
</span>
|
|
24
|
+
<span class="aichat__titles">
|
|
25
|
+
<span class="aichat__title">AI 开发助手</span>
|
|
26
|
+
<span class="aichat__sub">
|
|
27
|
+
<template v-if="cardId">{{ cardName || cardId }}</template>
|
|
28
|
+
<template v-else>未选择卡片</template>
|
|
29
|
+
<template v-if="config.model"> · {{ config.model }}</template>
|
|
30
|
+
</span>
|
|
31
|
+
</span>
|
|
32
|
+
<span class="aichat__acts">
|
|
33
|
+
<button type="button" class="aichat__iconbtn" title="清空本卡片对话记录" :disabled="!messages.length || busy" @click="askClear">
|
|
34
|
+
<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-trash" /></svg>
|
|
35
|
+
</button>
|
|
36
|
+
<button type="button" class="aichat__iconbtn" title="模型设置" @click="openSettings">
|
|
37
|
+
<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-settings" /></svg>
|
|
38
|
+
</button>
|
|
39
|
+
<button type="button" class="aichat__iconbtn" title="关闭(Esc)" @click="$emit('close')">
|
|
40
|
+
<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>
|
|
41
|
+
</button>
|
|
42
|
+
</span>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<!-- 未配置模型:常驻提示条,一键打开设置 -->
|
|
46
|
+
<div v-if="!configReady" class="aichat__banner">
|
|
47
|
+
<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>
|
|
48
|
+
<span>尚未配置模型,无法对话。</span>
|
|
49
|
+
<button type="button" class="aichat__banner-btn" @click="openSettings">去设置</button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- 消息区:滚动容器与悬浮「回到底部」共处一个定位上下文,按钮只贴消息区底缘,
|
|
53
|
+
输入区拖高 / 反馈条出现都不会把它盖住或悬空 -->
|
|
54
|
+
<div class="aichat__scroll">
|
|
55
|
+
<div ref="listEl" class="aichat__list" @scroll="onScroll">
|
|
56
|
+
<div v-if="loadingHistory" class="aichat__loading">正在加载对话记录…</div>
|
|
57
|
+
|
|
58
|
+
<div v-else-if="!messages.length" class="aichat__empty">
|
|
59
|
+
<span class="aichat__empty-logo">
|
|
60
|
+
<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-sparkles" /></svg>
|
|
61
|
+
</span>
|
|
62
|
+
<p class="aichat__empty-title">让 AI 直接改这张卡</p>
|
|
63
|
+
<p class="aichat__empty-desc">
|
|
64
|
+
描述需求即可,AI 会调用卡片工具读改校验并截图自检;也可以把设计稿标注目录、参考图拖进来,只传本机路径不上传。
|
|
65
|
+
</p>
|
|
66
|
+
<div class="aichat__suggests">
|
|
67
|
+
<button v-for="s in suggestions" :key="s.text" type="button" class="aichat__suggest" @click="useSuggestion(s)">
|
|
68
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use :href="s.icon" /></svg>
|
|
69
|
+
<span>{{ s.label }}</span>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<template v-else>
|
|
75
|
+
<ChatMessage
|
|
76
|
+
v-for="(msg, idx) in messages"
|
|
77
|
+
:key="msg.id"
|
|
78
|
+
:msg="msg"
|
|
79
|
+
:streaming="busy && idx === messages.length - 1"
|
|
80
|
+
:can-regenerate="msg.role === 'assistant' && idx === messages.length - 1"
|
|
81
|
+
@regenerate="regenerate"
|
|
82
|
+
@approval="onApproval"
|
|
83
|
+
@continue="onContinue"
|
|
84
|
+
/>
|
|
85
|
+
<div v-if="busy" class="aichat__live">生成中… 可随时终止</div>
|
|
86
|
+
</template>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- 回到底部:脱离底部吸附时出现 -->
|
|
90
|
+
<button v-show="!pinned && messages.length" type="button" class="aichat__jump" title="回到底部" @click="scrollToBottom(true)">
|
|
91
|
+
<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-arrow-down" /></svg>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- 操作反馈(附件解析失败 / 历史保存失败等) -->
|
|
96
|
+
<div v-if="notice" class="aichat__notice" :class="`aichat__notice--${notice.type}`">
|
|
97
|
+
<span>{{ notice.text }}</span>
|
|
98
|
+
<button type="button" class="aichat__notice-x" title="关闭" @click="notice = null">
|
|
99
|
+
<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>
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<ChatComposer
|
|
104
|
+
ref="composerEl"
|
|
105
|
+
:busy="busy"
|
|
106
|
+
:model="config.model"
|
|
107
|
+
:attachments="draftAttachments"
|
|
108
|
+
:placeholder="configReady ? '描述你要改的卡片,Enter 发送 / Shift+Enter 换行' : '请先在设置中配置模型…'"
|
|
109
|
+
:context="contextUsage"
|
|
110
|
+
@send="onSend"
|
|
111
|
+
@stop="stop"
|
|
112
|
+
@pick="onPick"
|
|
113
|
+
@add-path="onAddPath"
|
|
114
|
+
@remove-attachment="removeAttachment"
|
|
115
|
+
@drop-files="onDropFiles"
|
|
116
|
+
@paste-image="onPasteImage"
|
|
117
|
+
@escape="onEscape"
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<!-- 模型设置(面板内浮层,不额外抬 z-index) -->
|
|
121
|
+
<AiSettings v-if="settingsOpen" :config="config" @close="settingsOpen = false" @saved="onConfigSaved" />
|
|
122
|
+
|
|
123
|
+
<ConfirmDialog
|
|
124
|
+
:visible="confirmClear"
|
|
125
|
+
title="清空对话记录"
|
|
126
|
+
:message="`将删除卡片「${cardName || cardId || '当前卡片'}」的全部对话记录,此操作不可撤销。`"
|
|
127
|
+
confirm-text="清空"
|
|
128
|
+
severity="danger"
|
|
129
|
+
@confirm="doClear"
|
|
130
|
+
@cancel="confirmClear = false"
|
|
131
|
+
/>
|
|
132
|
+
</aside>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<script>
|
|
136
|
+
import ChatMessage from './ChatMessage.vue'
|
|
137
|
+
import ChatComposer from './ChatComposer.vue'
|
|
138
|
+
import AiSettings from './AiSettings.vue'
|
|
139
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
140
|
+
import {
|
|
141
|
+
fetchAiConfig,
|
|
142
|
+
loadChatHistory,
|
|
143
|
+
saveChatHistory,
|
|
144
|
+
clearChatHistory,
|
|
145
|
+
approveToolCall,
|
|
146
|
+
pickLocalPaths,
|
|
147
|
+
statLocalPath,
|
|
148
|
+
locateDroppedFile,
|
|
149
|
+
stashPastedImage,
|
|
150
|
+
streamChat,
|
|
151
|
+
} from '../runtime/ai-api.js'
|
|
152
|
+
|
|
153
|
+
const WIDTH_KEY = 'cardbuddy:ai-panel-width'
|
|
154
|
+
/** 宽度约束:不小于 320px、不大于视口 50%,默认 33% */
|
|
155
|
+
const MIN_WIDTH = 320
|
|
156
|
+
const MAX_RATIO = 0.5
|
|
157
|
+
const DEFAULT_RATIO = 0.33
|
|
158
|
+
|
|
159
|
+
let _seq = 0
|
|
160
|
+
function uid(prefix = 'm') {
|
|
161
|
+
_seq += 1
|
|
162
|
+
return `${prefix}${Date.now().toString(36)}-${_seq}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 建议问法:点一下即填入输入框(不自动发送,留给用户改写) */
|
|
166
|
+
const SUGGESTIONS = [
|
|
167
|
+
{ label: '解释这张卡的结构与数据来源', icon: '#icon-search', text: '读取当前卡片 schema,用中文解释它的结构、关键绑定与数据来源,指出可能的问题。' },
|
|
168
|
+
{ label: '校验并修复当前卡片', icon: '#icon-check', text: '对当前卡片执行校验,列出全部错误并按规则修复,修完再校验一次确认清零。' },
|
|
169
|
+
{ label: '截图自检还原度', icon: '#icon-image', text: '给当前卡片截图,检查布局、字号、间距与配色是否协调,指出需要调整的地方。' },
|
|
170
|
+
{ label: '按标注目录建卡', icon: '#icon-folder-plus', text: '我会挂载一份 Sketch MeaXure 标注导出目录,请按标注生成卡片 schema 并自检。' },
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
export default {
|
|
174
|
+
name: 'ChatPanel',
|
|
175
|
+
components: { ChatMessage, ChatComposer, AiSettings, ConfirmDialog },
|
|
176
|
+
props: {
|
|
177
|
+
visible: { type: Boolean, default: false },
|
|
178
|
+
/** 当前卡片 id:对话记录按它分档保存 */
|
|
179
|
+
cardId: { type: String, default: '' },
|
|
180
|
+
/** 当前卡片名(头部展示) */
|
|
181
|
+
cardName: { type: String, default: '' },
|
|
182
|
+
},
|
|
183
|
+
emits: ['close'],
|
|
184
|
+
data() {
|
|
185
|
+
return {
|
|
186
|
+
messages: [],
|
|
187
|
+
draftAttachments: [],
|
|
188
|
+
busy: false,
|
|
189
|
+
loadingHistory: false,
|
|
190
|
+
settingsOpen: false,
|
|
191
|
+
confirmClear: false,
|
|
192
|
+
notice: null,
|
|
193
|
+
config: { baseUrl: '', model: '', hasKey: false, apiKeyMasked: '' },
|
|
194
|
+
widthPx: 0,
|
|
195
|
+
winW: typeof window !== 'undefined' ? window.innerWidth : 1280,
|
|
196
|
+
resizing: false,
|
|
197
|
+
pinned: true,
|
|
198
|
+
/** 上下文占用(后端每轮 context-usage 回传):{ used, total, window },输入框圆环的数据源 */
|
|
199
|
+
contextUsage: null,
|
|
200
|
+
suggestions: SUGGESTIONS,
|
|
201
|
+
_abort: null,
|
|
202
|
+
_saveTimer: 0,
|
|
203
|
+
_loadedCard: '',
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
computed: {
|
|
207
|
+
configReady() {
|
|
208
|
+
return Boolean(this.config.baseUrl && this.config.model && this.config.hasKey)
|
|
209
|
+
},
|
|
210
|
+
/** 面板宽度:夹取到 [320, 视口 50%],未设置时取视口 33% */
|
|
211
|
+
panelWidth() {
|
|
212
|
+
const vw = this.winW || 1280
|
|
213
|
+
const max = Math.max(MIN_WIDTH, Math.floor(vw * MAX_RATIO))
|
|
214
|
+
const fallback = Math.round(vw * DEFAULT_RATIO)
|
|
215
|
+
const w = this.widthPx || fallback
|
|
216
|
+
return Math.max(MIN_WIDTH, Math.min(max, w))
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
watch: {
|
|
220
|
+
visible(val) {
|
|
221
|
+
if (val) {
|
|
222
|
+
this.refreshConfig()
|
|
223
|
+
this.ensureHistory()
|
|
224
|
+
this.$nextTick(() => {
|
|
225
|
+
this.scrollToBottom(true)
|
|
226
|
+
this.$refs.composerEl?.focus()
|
|
227
|
+
})
|
|
228
|
+
} else if (this.busy) {
|
|
229
|
+
// 关面板不打断生成:继续跑完并落盘,重开可见结果
|
|
230
|
+
this.flushHistory()
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
cardId(newId, oldId) {
|
|
234
|
+
// 切卡:先把「旧卡」未落盘的消息按旧卡 ID 落盘(此刻 this.cardId 已是新卡,
|
|
235
|
+
// 直接 flush 会把旧卡消息写进新卡的历史文件),再清空视图加载新卡记录
|
|
236
|
+
if (this._saveTimer) {
|
|
237
|
+
clearTimeout(this._saveTimer)
|
|
238
|
+
this._saveTimer = 0
|
|
239
|
+
}
|
|
240
|
+
const pending = this._dirtyFor === oldId ? this.serialize() : null
|
|
241
|
+
this._dirtyFor = null
|
|
242
|
+
this.draftAttachments = []
|
|
243
|
+
this.messages = []
|
|
244
|
+
if (pending && oldId) this.flushHistory(oldId, pending)
|
|
245
|
+
this.ensureHistory()
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
mounted() {
|
|
249
|
+
this.loadWidth()
|
|
250
|
+
window.addEventListener('resize', this.onWinResize)
|
|
251
|
+
this.refreshConfig()
|
|
252
|
+
this.ensureHistory()
|
|
253
|
+
},
|
|
254
|
+
beforeUnmount() {
|
|
255
|
+
window.removeEventListener('resize', this.onWinResize)
|
|
256
|
+
this.stopTickers()
|
|
257
|
+
this.flushHistory()
|
|
258
|
+
if (this._abort) this._abort.abort()
|
|
259
|
+
},
|
|
260
|
+
methods: {
|
|
261
|
+
// ---------- 宽度 ----------
|
|
262
|
+
onWinResize() {
|
|
263
|
+
this.winW = window.innerWidth
|
|
264
|
+
},
|
|
265
|
+
loadWidth() {
|
|
266
|
+
try {
|
|
267
|
+
const v = localStorage.getItem(WIDTH_KEY)
|
|
268
|
+
if (v != null) {
|
|
269
|
+
const px = parseInt(v, 10)
|
|
270
|
+
if (!Number.isNaN(px) && px >= MIN_WIDTH) this.widthPx = px
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
/* localStorage 不可用时用默认比例 */
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
saveWidth() {
|
|
277
|
+
try {
|
|
278
|
+
if (this.widthPx) localStorage.setItem(WIDTH_KEY, String(Math.round(this.panelWidth)))
|
|
279
|
+
else localStorage.removeItem(WIDTH_KEY)
|
|
280
|
+
} catch {
|
|
281
|
+
/* 忽略 */
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
startResize(ev) {
|
|
285
|
+
const el = ev && ev.currentTarget
|
|
286
|
+
if (!el) return
|
|
287
|
+
this.resizing = true
|
|
288
|
+
document.body.style.cursor = 'col-resize'
|
|
289
|
+
document.body.style.userSelect = 'none'
|
|
290
|
+
const pointerId = ev.pointerId
|
|
291
|
+
try {
|
|
292
|
+
el.setPointerCapture(pointerId)
|
|
293
|
+
} catch {
|
|
294
|
+
/* 不支持捕获时退化为元素级监听 */
|
|
295
|
+
}
|
|
296
|
+
const move = (e) => {
|
|
297
|
+
const max = Math.max(MIN_WIDTH, Math.floor(window.innerWidth * MAX_RATIO))
|
|
298
|
+
this.widthPx = Math.max(MIN_WIDTH, Math.min(max, Math.round(window.innerWidth - e.clientX)))
|
|
299
|
+
}
|
|
300
|
+
const finish = () => {
|
|
301
|
+
el.removeEventListener('pointermove', move)
|
|
302
|
+
el.removeEventListener('pointerup', finish)
|
|
303
|
+
el.removeEventListener('pointercancel', finish)
|
|
304
|
+
window.removeEventListener('blur', finish)
|
|
305
|
+
try {
|
|
306
|
+
if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
|
|
307
|
+
} catch {
|
|
308
|
+
/* 已释放 */
|
|
309
|
+
}
|
|
310
|
+
this.resizing = false
|
|
311
|
+
document.body.style.cursor = ''
|
|
312
|
+
document.body.style.userSelect = ''
|
|
313
|
+
this.saveWidth()
|
|
314
|
+
}
|
|
315
|
+
el.addEventListener('pointermove', move)
|
|
316
|
+
el.addEventListener('pointerup', finish)
|
|
317
|
+
el.addEventListener('pointercancel', finish)
|
|
318
|
+
window.addEventListener('blur', finish) // 失焦兜底,避免拖拽态卡死
|
|
319
|
+
},
|
|
320
|
+
resetWidth() {
|
|
321
|
+
this.widthPx = 0
|
|
322
|
+
this.saveWidth()
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// ---------- 配置 ----------
|
|
326
|
+
async refreshConfig() {
|
|
327
|
+
try {
|
|
328
|
+
const cfg = await fetchAiConfig()
|
|
329
|
+
// 兼容旧版后端:无 hasKey 字段、直接回明文 apiKey,按「有 key」归一,避免误报「尚未配置」
|
|
330
|
+
this.config = { ...cfg, hasKey: Boolean(cfg.hasKey || cfg.apiKey) }
|
|
331
|
+
} catch {
|
|
332
|
+
this.config = { baseUrl: '', model: '', hasKey: false, apiKeyMasked: '' }
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
onConfigSaved(cfg) {
|
|
336
|
+
if (cfg) this.config = cfg
|
|
337
|
+
this.settingsOpen = false
|
|
338
|
+
this.refreshConfig()
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
// ---------- 历史 ----------
|
|
342
|
+
async ensureHistory() {
|
|
343
|
+
const cardId = this.cardId
|
|
344
|
+
if (!cardId || this._loadedCard === cardId) return
|
|
345
|
+
this._loadedCard = cardId
|
|
346
|
+
this.loadingHistory = true
|
|
347
|
+
try {
|
|
348
|
+
const data = await loadChatHistory(cardId)
|
|
349
|
+
// 期间可能已切卡:只有仍是同一张卡才落地
|
|
350
|
+
if (this.cardId !== cardId) return
|
|
351
|
+
const loaded = (data.messages || []).map((m) => this.revive(m))
|
|
352
|
+
// 历史是异步落地的,而面板一打开就能发消息:若这期间用户已经发过(甚至正在生成),
|
|
353
|
+
// 整体替换会把这轮对话从视图里抹掉(请求还在跑,界面却像凭空消失)。
|
|
354
|
+
// 故按 id 去重后把「已加载的历史」拼在当前会话消息之前。
|
|
355
|
+
const seen = new Set(loaded.map((m) => m.id))
|
|
356
|
+
const live = this.messages.filter((m) => !seen.has(m.id))
|
|
357
|
+
this.messages = [...loaded, ...live]
|
|
358
|
+
this.contextUsage = null // 换卡后旧卡的占用数字没有意义,等新一轮回传
|
|
359
|
+
this._dirtyFor = null // 视图与落盘一致,切卡时不必再回写
|
|
360
|
+
this.$nextTick(() => this.scrollToBottom(true))
|
|
361
|
+
} catch {
|
|
362
|
+
if (this.cardId === cardId) this.messages = []
|
|
363
|
+
this._dirtyFor = null
|
|
364
|
+
} finally {
|
|
365
|
+
if (this.cardId === cardId) this.loadingHistory = false
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
/** 落盘前剥离易失字段(流式态 / 计时器 / base64 截图) */
|
|
369
|
+
serialize() {
|
|
370
|
+
return this.messages.map((m) => {
|
|
371
|
+
const out = {
|
|
372
|
+
id: m.id,
|
|
373
|
+
role: m.role,
|
|
374
|
+
content: m.content || '',
|
|
375
|
+
ts: m.ts,
|
|
376
|
+
}
|
|
377
|
+
if (m.role === 'assistant') {
|
|
378
|
+
out.reasoning = m.reasoning || ''
|
|
379
|
+
out.reasoningMs = m.reasoningMs || 0
|
|
380
|
+
out.error = m.error || ''
|
|
381
|
+
out.status = m.status === 'streaming' ? 'aborted' : m.status || 'done'
|
|
382
|
+
// 收尾原因一并落盘(死循环熔断 / 墙钟 / 用户自设硬顶)
|
|
383
|
+
if (m.budget?.message) out.budget = { reason: m.budget.reason || '', message: m.budget.message }
|
|
384
|
+
if (m.toolCalls?.length) {
|
|
385
|
+
out.toolCalls = m.toolCalls.map((tc) => ({
|
|
386
|
+
toolCallId: tc.toolCallId,
|
|
387
|
+
toolName: tc.toolName,
|
|
388
|
+
args: tc.args || {},
|
|
389
|
+
result: tc.result,
|
|
390
|
+
error: tc.error || '',
|
|
391
|
+
ms: tc.ms || 0,
|
|
392
|
+
// 审批 / 免确认痕迹一并落盘:重开面板后仍看得见「当时跑了什么命令、是谁批的」
|
|
393
|
+
command: tc.command || '',
|
|
394
|
+
approvalNote: tc.approvalNote || '',
|
|
395
|
+
autoApproved: Boolean(tc.autoApproved),
|
|
396
|
+
}))
|
|
397
|
+
}
|
|
398
|
+
} else if (m.attachments?.length) {
|
|
399
|
+
out.attachments = m.attachments.map((a) => ({
|
|
400
|
+
path: a.path,
|
|
401
|
+
name: a.name,
|
|
402
|
+
isDir: Boolean(a.isDir),
|
|
403
|
+
isImage: Boolean(a.isImage),
|
|
404
|
+
size: a.size || 0,
|
|
405
|
+
entryCount: a.entryCount || 0,
|
|
406
|
+
exists: a.exists !== false,
|
|
407
|
+
}))
|
|
408
|
+
}
|
|
409
|
+
return out
|
|
410
|
+
})
|
|
411
|
+
},
|
|
412
|
+
/** 读回时补默认值,保证旧记录也能渲染 */
|
|
413
|
+
revive(m) {
|
|
414
|
+
return {
|
|
415
|
+
id: m.id || uid(),
|
|
416
|
+
role: m.role === 'user' ? 'user' : 'assistant',
|
|
417
|
+
content: m.content || '',
|
|
418
|
+
reasoning: m.reasoning || '',
|
|
419
|
+
reasoningMs: m.reasoningMs || 0,
|
|
420
|
+
toolCalls: Array.isArray(m.toolCalls)
|
|
421
|
+
? m.toolCalls.map((tc) =>
|
|
422
|
+
// 被终止的轮次可能留下「参数 / 结果没等到」的悬空调用:读回时钉成终止态,
|
|
423
|
+
// 否则前端会永远转圈显示「参数生成中…」
|
|
424
|
+
(m.status === 'aborted' || m.status === 'streaming') && tc && !tc.error && tc.result == null
|
|
425
|
+
? { ...tc, error: '已终止:该调用未拿到结果(轮次被中断)' }
|
|
426
|
+
: tc,
|
|
427
|
+
)
|
|
428
|
+
: [],
|
|
429
|
+
attachments: Array.isArray(m.attachments) ? m.attachments : [],
|
|
430
|
+
error: m.error || '',
|
|
431
|
+
status: m.status || 'done',
|
|
432
|
+
// 收尾原因(死循环 / 墙钟 / 步数硬顶):重开面板仍要看得见这轮为什么停
|
|
433
|
+
budget: m.budget?.message ? { reason: m.budget.reason || '', message: m.budget.message } : undefined,
|
|
434
|
+
ts: m.ts || 0,
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
persist() {
|
|
438
|
+
if (!this.cardId) return
|
|
439
|
+
this._dirtyFor = this.cardId
|
|
440
|
+
if (this._saveTimer) clearTimeout(this._saveTimer)
|
|
441
|
+
this._saveTimer = setTimeout(() => this.flushHistory(), 500)
|
|
442
|
+
},
|
|
443
|
+
async flushHistory(cardId = this.cardId, snapshot = null) {
|
|
444
|
+
if (this._saveTimer) {
|
|
445
|
+
clearTimeout(this._saveTimer)
|
|
446
|
+
this._saveTimer = 0
|
|
447
|
+
}
|
|
448
|
+
if (!cardId) return
|
|
449
|
+
try {
|
|
450
|
+
await saveChatHistory(cardId, snapshot || this.serialize())
|
|
451
|
+
if (this._dirtyFor === cardId) this._dirtyFor = null
|
|
452
|
+
} catch (e) {
|
|
453
|
+
this.notify('warn', `对话记录保存失败:${e.message || e}`)
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
askClear() {
|
|
457
|
+
if (!this.messages.length) return
|
|
458
|
+
this.confirmClear = true
|
|
459
|
+
},
|
|
460
|
+
async doClear() {
|
|
461
|
+
this.confirmClear = false
|
|
462
|
+
const cardId = this.cardId
|
|
463
|
+
this.messages = []
|
|
464
|
+
this.contextUsage = null
|
|
465
|
+
this._loadedCard = cardId
|
|
466
|
+
this._dirtyFor = null
|
|
467
|
+
if (this._saveTimer) {
|
|
468
|
+
clearTimeout(this._saveTimer)
|
|
469
|
+
this._saveTimer = 0
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
await clearChatHistory(cardId)
|
|
473
|
+
this.notify('ok', '已清空本卡片的对话记录')
|
|
474
|
+
} catch (e) {
|
|
475
|
+
this.notify('warn', `清空失败:${e.message || e}`)
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
// ---------- 附件 ----------
|
|
480
|
+
async onPick(kind) {
|
|
481
|
+
try {
|
|
482
|
+
const res = await pickLocalPaths(kind)
|
|
483
|
+
if (res.cancelled) return
|
|
484
|
+
for (const item of res.items || []) this.pushAttachment(item)
|
|
485
|
+
if (!res.items?.length) this.notify('warn', '未选到可用文件')
|
|
486
|
+
} catch (e) {
|
|
487
|
+
this.notify('warn', e.message || '无法调起系统选择器,可改用拖拽或粘贴路径')
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
async onAddPath(rawPath) {
|
|
491
|
+
try {
|
|
492
|
+
const info = await statLocalPath(rawPath)
|
|
493
|
+
this.pushAttachment(info)
|
|
494
|
+
} catch (e) {
|
|
495
|
+
this.notify('warn', `路径不可用:${rawPath}(${e.message || e})`)
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
async onPasteImage(dataUrl) {
|
|
499
|
+
try {
|
|
500
|
+
const info = await stashPastedImage(dataUrl)
|
|
501
|
+
this.pushAttachment(info, '粘贴的截图已存到本机 .cache/ai-attachments/')
|
|
502
|
+
} catch (e) {
|
|
503
|
+
this.notify('warn', `截图保存失败:${e.message || e}`)
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
/**
|
|
507
|
+
* 拖入文件 / 文件夹:浏览器不给绝对路径,先按「名字 + 大小」请后端反查;
|
|
508
|
+
* 文件夹则递归收集一层清单交给 AI(仍只传路径,不上传内容)。
|
|
509
|
+
*/
|
|
510
|
+
async onDropFiles(dataTransfer) {
|
|
511
|
+
const items = Array.from(dataTransfer.items || [])
|
|
512
|
+
const entries = items.map((it) => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null)).filter(Boolean)
|
|
513
|
+
const files = Array.from(dataTransfer.files || [])
|
|
514
|
+
const jobs = []
|
|
515
|
+
|
|
516
|
+
if (entries.length) {
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
jobs.push(this.resolveEntry(entry))
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
for (const file of files) {
|
|
522
|
+
jobs.push(this.resolveFile(file))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (!jobs.length) {
|
|
526
|
+
this.notify('warn', '没有识别到可挂载的文件')
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const results = await Promise.all(jobs)
|
|
530
|
+
const missed = results.filter((r) => !r).length
|
|
531
|
+
if (missed) {
|
|
532
|
+
this.notify('warn', `${missed} 项未能定位本机路径,可改用「粘贴路径」添加`)
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
async resolveEntry(entry) {
|
|
536
|
+
if (entry.isDirectory) {
|
|
537
|
+
const file = await this.firstFileInDir(entry)
|
|
538
|
+
return this.resolveByName(entry.name, entry.fullPath || entry.name, file?.size ?? -1)
|
|
539
|
+
}
|
|
540
|
+
const file = await this.entryToFile(entry)
|
|
541
|
+
if (file) return this.resolveFile(file)
|
|
542
|
+
return this.resolveByName(entry.name, entry.fullPath || entry.name, -1)
|
|
543
|
+
},
|
|
544
|
+
async resolveFile(file) {
|
|
545
|
+
return this.resolveByName(file.name, file.webkitRelativePath || file.name, file.size)
|
|
546
|
+
},
|
|
547
|
+
async resolveByName(name, relPath, size) {
|
|
548
|
+
const chip = { path: '', name, isDir: false, isImage: false, pending: true }
|
|
549
|
+
this.draftAttachments = [...this.draftAttachments, chip]
|
|
550
|
+
try {
|
|
551
|
+
const info = await locateDroppedFile({ name, relPath, size })
|
|
552
|
+
this.draftAttachments = this.draftAttachments.filter((a) => a !== chip)
|
|
553
|
+
this.pushAttachment(info)
|
|
554
|
+
return info
|
|
555
|
+
} catch {
|
|
556
|
+
this.draftAttachments = this.draftAttachments.filter((a) => a !== chip)
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
entryToFile(entry) {
|
|
561
|
+
return new Promise((resolve) => {
|
|
562
|
+
try {
|
|
563
|
+
entry.file((f) => resolve(f), () => resolve(null))
|
|
564
|
+
} catch {
|
|
565
|
+
resolve(null)
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
},
|
|
569
|
+
/** 文件夹拖入:取第一个文件的大小做匹配特征(目录本身没有 size) */
|
|
570
|
+
firstFileInDir(entry) {
|
|
571
|
+
return new Promise((resolve) => {
|
|
572
|
+
try {
|
|
573
|
+
const reader = entry.createReader()
|
|
574
|
+
reader.readEntries(async (list) => {
|
|
575
|
+
for (const ent of list || []) {
|
|
576
|
+
if (!ent.isDirectory) {
|
|
577
|
+
const f = await this.entryToFile(ent)
|
|
578
|
+
if (f) return resolve(f)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
resolve(null)
|
|
582
|
+
}, () => resolve(null))
|
|
583
|
+
} catch {
|
|
584
|
+
resolve(null)
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
pushAttachment(info, note) {
|
|
589
|
+
if (!info?.path) return
|
|
590
|
+
if (this.draftAttachments.some((a) => a.path === info.path)) return
|
|
591
|
+
this.draftAttachments = [...this.draftAttachments, { ...info, note }]
|
|
592
|
+
if (note) this.notify('ok', note)
|
|
593
|
+
},
|
|
594
|
+
removeAttachment(pathToRemove) {
|
|
595
|
+
this.draftAttachments = this.draftAttachments.filter((a) => a.path !== pathToRemove || a.pending)
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
// ---------- 对话 ----------
|
|
599
|
+
useSuggestion(s) {
|
|
600
|
+
this.$refs.composerEl?.setText(s.text)
|
|
601
|
+
},
|
|
602
|
+
async onSend({ text, attachments }) {
|
|
603
|
+
if (this.busy) return
|
|
604
|
+
if (!(await this.ensureConfig())) return
|
|
605
|
+
this.notice = null
|
|
606
|
+
this.messages.push({
|
|
607
|
+
id: uid('u'),
|
|
608
|
+
role: 'user',
|
|
609
|
+
content: text,
|
|
610
|
+
attachments: attachments.map((a) => ({ ...a })),
|
|
611
|
+
ts: Date.now(),
|
|
612
|
+
})
|
|
613
|
+
this.draftAttachments = []
|
|
614
|
+
this.pinned = true
|
|
615
|
+
await this.runTurn()
|
|
616
|
+
},
|
|
617
|
+
/** 打开设置面板前刷新一次:窗口探测缓存等「服务端认知」可能已被对话或探测按钮更新 */
|
|
618
|
+
openSettings() {
|
|
619
|
+
this.settingsOpen = true
|
|
620
|
+
this.refreshConfig()
|
|
621
|
+
},
|
|
622
|
+
async ensureConfig() {
|
|
623
|
+
await this.refreshConfig()
|
|
624
|
+
if (!this.configReady) {
|
|
625
|
+
this.settingsOpen = true
|
|
626
|
+
this.notify('warn', '请先配置 Base URL / API Key / 模型名')
|
|
627
|
+
return false
|
|
628
|
+
}
|
|
629
|
+
return true
|
|
630
|
+
},
|
|
631
|
+
/** 熔断收尾后一键续跑:等价于用户手打「继续」,上下文(含工具结果)都还在 */
|
|
632
|
+
onContinue() {
|
|
633
|
+
if (this.busy) return
|
|
634
|
+
this.onSend({ text: '继续', attachments: [] })
|
|
635
|
+
},
|
|
636
|
+
/** 重新生成:丢掉最后一条助手回答,按同样的上下文再跑一轮 */
|
|
637
|
+
async regenerate(msg) {
|
|
638
|
+
if (this.busy) return
|
|
639
|
+
const idx = this.messages.findIndex((m) => m.id === msg.id)
|
|
640
|
+
if (idx > 0) this.messages = this.messages.slice(0, idx)
|
|
641
|
+
await this.runTurn()
|
|
642
|
+
},
|
|
643
|
+
async runTurn() {
|
|
644
|
+
this.messages.push({
|
|
645
|
+
id: uid('a'),
|
|
646
|
+
role: 'assistant',
|
|
647
|
+
content: '',
|
|
648
|
+
reasoning: '',
|
|
649
|
+
reasoningMs: 0,
|
|
650
|
+
toolCalls: [],
|
|
651
|
+
error: '',
|
|
652
|
+
status: 'streaming',
|
|
653
|
+
ts: Date.now(),
|
|
654
|
+
})
|
|
655
|
+
// 关键:改的必须是「reactive 数组里的代理对象」。若沿用 push 前的原始对象,
|
|
656
|
+
// 写入不会触发任何依赖 —— 流式期间界面会一直停在「正在思考」,直到回合结束
|
|
657
|
+
// 父组件因 busy 变化重渲染,才把累积的全文一次性刷出来(看着像卡死后吐一大坨)。
|
|
658
|
+
const asst = this.messages[this.messages.length - 1]
|
|
659
|
+
const asstId = asst.id
|
|
660
|
+
this.busy = true
|
|
661
|
+
this.pinned = true
|
|
662
|
+
const ctl = new AbortController()
|
|
663
|
+
this._abort = ctl
|
|
664
|
+
const thinkStartedAt = Date.now()
|
|
665
|
+
const freezeThinking = () => {
|
|
666
|
+
if (asst.reasoning && !asst.reasoningMs) asst.reasoningMs = Date.now() - thinkStartedAt
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
await streamChat({
|
|
671
|
+
messages: this.toWire(this.messages.filter((m) => m.id !== asstId)),
|
|
672
|
+
cardContext: this.cardId ? { id: this.cardId } : undefined,
|
|
673
|
+
attachments: this.lastAttachments(),
|
|
674
|
+
signal: ctl.signal,
|
|
675
|
+
onEvent: (evt) => this.onEvent(evt, asst, freezeThinking),
|
|
676
|
+
})
|
|
677
|
+
if (asst.status === 'streaming') asst.status = 'done'
|
|
678
|
+
} catch (e) {
|
|
679
|
+
const aborted = ctl.signal.aborted || /abort/i.test(String(e?.message || e))
|
|
680
|
+
freezeThinking()
|
|
681
|
+
if (aborted) {
|
|
682
|
+
asst.status = 'aborted'
|
|
683
|
+
} else {
|
|
684
|
+
asst.error = String(e?.message || e)
|
|
685
|
+
asst.status = 'error'
|
|
686
|
+
}
|
|
687
|
+
} finally {
|
|
688
|
+
freezeThinking()
|
|
689
|
+
if (asst.status === 'streaming') asst.status = 'done'
|
|
690
|
+
this.busy = false
|
|
691
|
+
this._abort = null
|
|
692
|
+
this.persist()
|
|
693
|
+
this.$nextTick(() => this.scrollToBottom())
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
onEvent(evt, asst, freezeThinking) {
|
|
697
|
+
switch (evt.type) {
|
|
698
|
+
case 'reasoning-delta':
|
|
699
|
+
asst.reasoning += evt.text || ''
|
|
700
|
+
break
|
|
701
|
+
case 'text-delta':
|
|
702
|
+
freezeThinking()
|
|
703
|
+
asst.content += evt.text || ''
|
|
704
|
+
break
|
|
705
|
+
// 工具名先到、参数还在流式拼装:先占一行「调用中」,参数齐了由 tool-call 补齐
|
|
706
|
+
case 'tool-call-start':
|
|
707
|
+
freezeThinking()
|
|
708
|
+
if (!this.findToolRow(asst, evt)) {
|
|
709
|
+
asst.toolCalls.push({
|
|
710
|
+
toolCallId: evt.toolCallId || uid('tc'),
|
|
711
|
+
toolName: evt.toolName,
|
|
712
|
+
args: {},
|
|
713
|
+
pending: true,
|
|
714
|
+
result: undefined,
|
|
715
|
+
error: '',
|
|
716
|
+
startedAt: Date.now(),
|
|
717
|
+
ms: 0,
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
break
|
|
721
|
+
case 'tool-call': {
|
|
722
|
+
freezeThinking()
|
|
723
|
+
const row = this.findToolRow(asst, evt)
|
|
724
|
+
if (row) {
|
|
725
|
+
if (evt.toolCallId) row.toolCallId = evt.toolCallId
|
|
726
|
+
if (evt.toolName) row.toolName = evt.toolName
|
|
727
|
+
row.args = evt.args || {}
|
|
728
|
+
row.pending = false
|
|
729
|
+
} else {
|
|
730
|
+
asst.toolCalls.push({
|
|
731
|
+
toolCallId: evt.toolCallId || uid('tc'),
|
|
732
|
+
toolName: evt.toolName,
|
|
733
|
+
args: evt.args || {},
|
|
734
|
+
result: undefined,
|
|
735
|
+
error: '',
|
|
736
|
+
startedAt: Date.now(),
|
|
737
|
+
ms: 0,
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
break
|
|
741
|
+
}
|
|
742
|
+
case 'tool-result': {
|
|
743
|
+
const tc = asst.toolCalls.find((t) => t.toolCallId === evt.toolCallId || (!evt.toolCallId && t.toolName === evt.toolName && t.result === undefined))
|
|
744
|
+
if (tc) {
|
|
745
|
+
tc.result = evt.result ?? ''
|
|
746
|
+
tc.truncated = Boolean(evt.truncated)
|
|
747
|
+
if (evt.error) tc.error = String(evt.error)
|
|
748
|
+
if (evt.image) tc.image = evt.image
|
|
749
|
+
tc.ms = Date.now() - (tc.startedAt || Date.now())
|
|
750
|
+
}
|
|
751
|
+
break
|
|
752
|
+
}
|
|
753
|
+
case 'approval-request': {
|
|
754
|
+
// skill_exec / shell_exec:先占行再等用户点确认,后端流在此期间挂起
|
|
755
|
+
const row = this.findToolRow(asst, evt) || asst.toolCalls[asst.toolCalls.length - 1]
|
|
756
|
+
if (row && row.toolName === evt.toolName && row.result === undefined) {
|
|
757
|
+
row.approvalId = evt.approvalId
|
|
758
|
+
// 只补 command(命令原文),不覆盖真实参数:evt.args 是审批用的 {command} 精简体
|
|
759
|
+
if (evt.command) row.command = evt.command
|
|
760
|
+
row.needsApproval = true
|
|
761
|
+
} else {
|
|
762
|
+
asst.toolCalls.push({
|
|
763
|
+
toolCallId: evt.toolCallId || uid('tc'),
|
|
764
|
+
toolName: evt.toolName,
|
|
765
|
+
args: evt.args || {},
|
|
766
|
+
command: evt.command || '',
|
|
767
|
+
approvalId: evt.approvalId,
|
|
768
|
+
needsApproval: true,
|
|
769
|
+
result: undefined,
|
|
770
|
+
error: '',
|
|
771
|
+
startedAt: Date.now(),
|
|
772
|
+
ms: 0,
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
break
|
|
776
|
+
}
|
|
777
|
+
case 'approval-decided': {
|
|
778
|
+
const row = (asst.toolCalls || []).find((t) => t.approvalId === evt.approvalId)
|
|
779
|
+
if (row) {
|
|
780
|
+
row.needsApproval = false
|
|
781
|
+
row.approvalNote = evt.ok
|
|
782
|
+
? evt.scope === 'turn'
|
|
783
|
+
? '已允许,本回合后续同类执行不再询问…'
|
|
784
|
+
: '已允许,执行中…'
|
|
785
|
+
: '已拒绝执行'
|
|
786
|
+
}
|
|
787
|
+
break
|
|
788
|
+
}
|
|
789
|
+
case 'approval-auto': {
|
|
790
|
+
// 免确认策略命中:后端没弹审批就直接执行了,这里把命令原文与说明补到对应工具行,
|
|
791
|
+
// 用户照样看得见「跑了什么」——免确认不等于无痕迹(后端另写 .cache/ai-audit.log)
|
|
792
|
+
const row =
|
|
793
|
+
this.findToolRow(asst, evt) ||
|
|
794
|
+
[...(asst.toolCalls || [])].reverse().find((t) => t.toolName === evt.toolName && t.result === undefined)
|
|
795
|
+
if (row) {
|
|
796
|
+
if (evt.command) row.command = evt.command
|
|
797
|
+
row.autoApproved = true
|
|
798
|
+
row.approvalNote = '按设置免确认执行'
|
|
799
|
+
}
|
|
800
|
+
break
|
|
801
|
+
}
|
|
802
|
+
case 'notice':
|
|
803
|
+
this.notify('warn', evt.message || '')
|
|
804
|
+
break
|
|
805
|
+
case 'context-usage':
|
|
806
|
+
// 输入框圆环:本轮实际送进网关的上下文 token / 预算(有网关窗口认知时环按窗口算)
|
|
807
|
+
this.contextUsage = { used: Number(evt.used) || 0, total: Number(evt.total) || 0, window: Number(evt.window) || 0 }
|
|
808
|
+
break
|
|
809
|
+
case 'turn-ended':
|
|
810
|
+
// 熔断 / 硬顶收尾:横幅 6 秒就没了,这里落进消息本体,重开面板仍看得见「这轮为什么停」
|
|
811
|
+
asst.budget = { reason: String(evt.reason || ''), message: String(evt.message || '') }
|
|
812
|
+
break
|
|
813
|
+
case 'error':
|
|
814
|
+
asst.error = asst.error ? `${asst.error}\n${evt.message}` : String(evt.message || '')
|
|
815
|
+
asst.status = 'error'
|
|
816
|
+
break
|
|
817
|
+
case 'aborted':
|
|
818
|
+
asst.status = 'aborted'
|
|
819
|
+
break
|
|
820
|
+
case 'done':
|
|
821
|
+
break
|
|
822
|
+
default:
|
|
823
|
+
break
|
|
824
|
+
}
|
|
825
|
+
this.$nextTick(() => this.scrollToBottom())
|
|
826
|
+
},
|
|
827
|
+
/** 审批回落:把用户的允许(一次 / 本回合)或拒绝交给挂起中的后端流 */
|
|
828
|
+
async onApproval({ tc, ok, scope = 'once' }) {
|
|
829
|
+
if (!tc?.approvalId) return
|
|
830
|
+
tc.needsApproval = false
|
|
831
|
+
tc.approvalNote = ok ? (scope === 'turn' ? '已允许,本回合后续同类执行不再询问…' : '已允许,执行中…') : '已拒绝执行'
|
|
832
|
+
try {
|
|
833
|
+
await approveToolCall({ approvalId: tc.approvalId, ok, scope })
|
|
834
|
+
} catch (e) {
|
|
835
|
+
this.notify('warn', `审批提交失败:${e.message}`)
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
/**
|
|
839
|
+
* 工具行定位:优先按 toolCallId,其次认「同名且仍在等参数」的占位行。
|
|
840
|
+
* 占位行的 id 是本地生成的,参数齐了会被真实 id 覆盖,故两条路径都能对上。
|
|
841
|
+
*/
|
|
842
|
+
findToolRow(asst, evt) {
|
|
843
|
+
const list = asst.toolCalls || []
|
|
844
|
+
if (evt.toolCallId) {
|
|
845
|
+
const byId = list.find((t) => t.toolCallId === evt.toolCallId)
|
|
846
|
+
if (byId) return byId
|
|
847
|
+
}
|
|
848
|
+
return list.find((t) => t.pending && t.toolName === evt.toolName) || null
|
|
849
|
+
},
|
|
850
|
+
stop() {
|
|
851
|
+
if (!this._abort) return
|
|
852
|
+
this._abort.abort()
|
|
853
|
+
this.notify('ok', '已终止本次生成')
|
|
854
|
+
},
|
|
855
|
+
onEscape() {
|
|
856
|
+
if (this.settingsOpen) {
|
|
857
|
+
this.settingsOpen = false
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
if (this.busy) {
|
|
861
|
+
this.stop()
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
this.$emit('close')
|
|
865
|
+
},
|
|
866
|
+
/** 最近一条用户消息挂载的附件(后端会写进 system prompt 的「本地文件」段) */
|
|
867
|
+
lastAttachments() {
|
|
868
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
869
|
+
const m = this.messages[i]
|
|
870
|
+
if (m.role === 'user') return (m.attachments || []).map((a) => ({ path: a.path, name: a.name, isDir: a.isDir, isImage: a.isImage }))
|
|
871
|
+
}
|
|
872
|
+
return []
|
|
873
|
+
},
|
|
874
|
+
/**
|
|
875
|
+
* 组装上行 messages:用户消息把附件路径写成清单(路径即上下文,不上传文件),
|
|
876
|
+
* 助手消息保留 toolCalls 供后端还原 tool_calls / tool 结果,续聊上下文才合法。
|
|
877
|
+
*/
|
|
878
|
+
toWire(list) {
|
|
879
|
+
return list.map((m) => {
|
|
880
|
+
if (m.role === 'user') {
|
|
881
|
+
const atts = m.attachments || []
|
|
882
|
+
let content = m.content || ''
|
|
883
|
+
if (atts.length) {
|
|
884
|
+
const lines = atts.map((a) => `- ${a.isDir ? '文件夹' : a.isImage ? '图片' : '文件'}:${a.path}`)
|
|
885
|
+
content = `${content}${content ? '\n\n' : ''}[已挂载本地文件(仅本机路径,未上传;需要内容请用工具读取)]\n${lines.join('\n')}`
|
|
886
|
+
}
|
|
887
|
+
return { role: 'user', content }
|
|
888
|
+
}
|
|
889
|
+
// 被中止的轮次:只回传「真正执行过」的工具调用(有结果或报错),
|
|
890
|
+
// 参数还没流完就被中止的调用从未执行,回传会骗模型说它拿到了空结果。
|
|
891
|
+
const calls = (m.toolCalls || [])
|
|
892
|
+
.filter((tc) => tc.result !== undefined || tc.error)
|
|
893
|
+
.map((tc) => ({
|
|
894
|
+
toolCallId: tc.toolCallId,
|
|
895
|
+
toolName: tc.toolName,
|
|
896
|
+
args: tc.args || {},
|
|
897
|
+
result: typeof tc.result === 'string' ? tc.result.slice(0, 4000) : tc.result,
|
|
898
|
+
error: tc.error || '',
|
|
899
|
+
}))
|
|
900
|
+
// 中止标记只进上下文、不进界面:让模型知道上一轮被打断、做到哪了,
|
|
901
|
+
// 用户说「继续」时能接着干而不是从零重来(空 assistant 消息对模型等于没有信息)。
|
|
902
|
+
let content = m.content || ''
|
|
903
|
+
if (m.status === 'aborted') {
|
|
904
|
+
content = content
|
|
905
|
+
? `${content}\n\n[本轮生成被用户手动中止,以上为已完成部分;未完成的步骤没有执行,请从中断处继续。]`
|
|
906
|
+
: '[上一轮被用户手动中止于思考/工具阶段,没有产出可用内容;请重新规划并继续完成用户的目标。]'
|
|
907
|
+
}
|
|
908
|
+
return { role: 'assistant', content, toolCalls: calls }
|
|
909
|
+
})
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
// ---------- 滚动 ----------
|
|
913
|
+
onScroll() {
|
|
914
|
+
const el = this.$refs.listEl
|
|
915
|
+
if (!el) return
|
|
916
|
+
this.pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
|
917
|
+
},
|
|
918
|
+
scrollToBottom(force = false) {
|
|
919
|
+
const el = this.$refs.listEl
|
|
920
|
+
if (!el) return
|
|
921
|
+
if (!force && !this.pinned) return
|
|
922
|
+
el.scrollTop = el.scrollHeight
|
|
923
|
+
},
|
|
924
|
+
stopTickers() {
|
|
925
|
+
if (this._saveTimer) clearTimeout(this._saveTimer)
|
|
926
|
+
this._saveTimer = 0
|
|
927
|
+
},
|
|
928
|
+
notify(type, text) {
|
|
929
|
+
this.notice = { type, text }
|
|
930
|
+
if (this._noticeTimer) clearTimeout(this._noticeTimer)
|
|
931
|
+
this._noticeTimer = setTimeout(() => {
|
|
932
|
+
this.notice = null
|
|
933
|
+
}, 6000)
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
}
|
|
937
|
+
</script>
|