优化: 对话交互修复与界面完善
- 修输入法组词回车直发/分离窗误关/出错后按钮死锁 - 新建任务失败弹窗内联提示,会话切换乐观高亮 - 状态多选联动刷新不再冲空可见行 - 测试连接保留已配开关与权重,防抖按提供商分键 - 代码块复制/长回复折叠带量/队列回填/排队时钟 - 子代理完成卡渲染,死链卡片与引用清理 - 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
+76
-19
@@ -57,8 +57,6 @@
|
||||
<span class="ai-agentic-progress-dot"></span>
|
||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
||||
</div>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
|
||||
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||
@@ -71,7 +69,7 @@
|
||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
|
||||
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}<span v-if="queueWaitSeconds >= 0" class="ai-queue-timer">({{ queueWaitSeconds }}s)</span></span>
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||
<div class="ai-queue-actions">
|
||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||
<button class="ai-queue-clear" @click="store.clearQueue(store.state.activeConversationId)">{{ $t('aiChat.clearQueue') }}</button>
|
||||
@@ -86,8 +84,7 @@
|
||||
v-model="editingQueueText"
|
||||
class="ai-queue-edit-input"
|
||||
type="text"
|
||||
@keydown.enter.prevent="saveEditQueued"
|
||||
@keydown.esc.prevent="cancelEditQueued"
|
||||
@keydown="onQueueEditKeydown"
|
||||
@blur="saveEditQueued"
|
||||
/>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')">✓</button>
|
||||
@@ -96,6 +93,10 @@
|
||||
<template v-else>
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<span class="ai-queue-text">{{ item.text }}</span>
|
||||
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
||||
<!-- 编辑按钮 -->
|
||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||
<!-- 立即发送(打断当前+发本条) -->
|
||||
@@ -141,7 +142,6 @@ import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||
import ChatInput from './ai/ChatInput.vue'
|
||||
import TopBar from './ai/TopBar.vue'
|
||||
import MessageList from './ai/MessageList.vue'
|
||||
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
|
||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
@@ -364,7 +364,14 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
|
||||
return
|
||||
}
|
||||
if (props.detached) {
|
||||
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
|
||||
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
|
||||
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
|
||||
// 浮层打开时焦点通常仍在 textarea(已被输入控件分支覆盖),补浮层容器类名兜
|
||||
// 「鼠标点过浮层项后焦点落在浮层内」的旁路(SkillMention/MentionPopover 共用 .ai-skill-popover 根类)。
|
||||
const el = e.target as HTMLElement | null
|
||||
const inInnerEscLayer = !!el?.closest?.('input, textarea, [contenteditable], .ai-skill-popover, .ai-skill-chip')
|
||||
if (props.detached && !inInnerEscLayer) {
|
||||
e.preventDefault()
|
||||
store.closeDetachedWindow()
|
||||
return
|
||||
@@ -409,12 +416,36 @@ const activeQueue = computed(() =>
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */
|
||||
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
|
||||
|
||||
/** 队首已排队秒数(用于 UI 展示倒计时;无队列时 -1) */
|
||||
const queueWaitSeconds = computed(() => {
|
||||
const first = activeQueue.value[0]
|
||||
if (!first) return -1
|
||||
return Math.floor((Date.now() - first.enqueuedAt) / 1000)
|
||||
})
|
||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
||||
const nowTick = ref(Date.now())
|
||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(
|
||||
() => activeQueue.value.length > 0,
|
||||
(waiting) => {
|
||||
if (waiting && queueTickTimer === null) {
|
||||
nowTick.value = Date.now()
|
||||
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
||||
} else if (!waiting && queueTickTimer !== null) {
|
||||
clearInterval(queueTickTimer)
|
||||
queueTickTimer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
||||
function formatWaitTime(totalSeconds: number): string {
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
||||
function queueItemWait(item: { enqueuedAt: number }): string {
|
||||
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
||||
}
|
||||
|
||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||
async function handleForceSend() {
|
||||
@@ -442,6 +473,20 @@ function cancelEditQueued() {
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
||||
* 收敛到本函数统一判。 */
|
||||
function onQueueEditKeydown(e: KeyboardEvent) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEditQueued()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditQueued()
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
||||
function saveEditQueued() {
|
||||
if (editingQueueIdx.value === null) return
|
||||
@@ -455,6 +500,20 @@ async function handleSendQueuedNow(idx: number) {
|
||||
await store.sendQueuedNow(idx)
|
||||
}
|
||||
|
||||
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
||||
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
||||
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
||||
* 与队列原地编辑只保 text 同一取舍。 */
|
||||
function backfillQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
const skill = item.skill
|
||||
? store.state.skills.find(s => s.name === item.skill) ?? null
|
||||
: null
|
||||
store.cancelQueued(idx)
|
||||
chatInputRef.value?.fillDraft(item.text, skill)
|
||||
}
|
||||
|
||||
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
|
||||
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
|
||||
// 父仅保留 scrollToBottom 转发桩(ChatInput @sent / TopBar @scroll-to-pending 经 expose 调)。
|
||||
@@ -462,6 +521,8 @@ function scrollToBottom(smooth = false) {
|
||||
messageListRef.value?.scrollToBottom(smooth)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
||||
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
||||
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||
@@ -580,10 +641,6 @@ async function toggleAlwaysOnTop() {
|
||||
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
|
||||
}
|
||||
|
||||
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
|
||||
// handleContinueLoop/handleStopLoop/pendingMaxRounds watch)已迁移至 ai/MaxRoundsCard.vue
|
||||
// (零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父)。
|
||||
|
||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
|
||||
// null | undefined | 'active' → 正常消息(现状)
|
||||
@@ -721,7 +778,6 @@ async function initContextEventListeners(): Promise<void> {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg);
|
||||
}
|
||||
/* 第五批抽离: F-260616-03 max_iterations 操作卡样式已迁移至 ai/MaxRoundsCard.vue */
|
||||
|
||||
/* ── Agentic 循环进度条 ── */
|
||||
.ai-agentic-progress {
|
||||
@@ -772,7 +828,6 @@ async function initContextEventListeners(): Promise<void> {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
|
||||
.ai-queue-timer { font-weight: 400; opacity: 0.7; }
|
||||
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
|
||||
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.ai-queue-force {
|
||||
@@ -793,6 +848,8 @@ async function initContextEventListeners(): Promise<void> {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||
/* V7 排队时钟:每项已等待时长 */
|
||||
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
|
||||
.ai-queue-text {
|
||||
color: var(--df-text);
|
||||
flex: 1;
|
||||
|
||||
@@ -116,6 +116,7 @@ import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { textIdle } from '../../composables/ai/useAiPendingState'
|
||||
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
|
||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||
import { uploadFile } from '../../composables/ai/fileUpload'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
@@ -292,7 +293,12 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
const convId = store.state.activeConversationId
|
||||
const cs = getConvState(convId)
|
||||
if (cs === 'stopping') return 'stopping'
|
||||
if (cs === 'error') return 'retry'
|
||||
// P1-3: conv_state='error' 是 AiError 收尾落的终态(useAiLifecycleEvents cleanup 先
|
||||
// setStreaming(false) 再置 error),此时本会话 loop 已死——「重试停止」按钮点 stopChat 对死
|
||||
// loop 无意义,却顶掉发送位,出错后鼠标用户点不了发送(Enter 不看按钮态仍可发,语义错位)。
|
||||
// 仅流式仍在跑(停止失败等 loop 未断的 error)时保留 retry 语义,否则回发送按钮,
|
||||
// 与 Enter/handleSend 对齐。
|
||||
if (cs === 'error') return store.state.streaming ? 'retry' : 'idle'
|
||||
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
|
||||
return textActive ? 'stop' : 'idle'
|
||||
})
|
||||
@@ -676,21 +682,29 @@ function selectMention(item: MentionItem) {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
|
||||
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
|
||||
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
|
||||
// 编辑态 Escape 取消编辑(最高优先级)
|
||||
if (props.editingMsgId && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('cancel-edit')
|
||||
return
|
||||
}
|
||||
// Escape 关闭技能联想浮层(无论是否有匹配项)
|
||||
if (skillOpen.value && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
skillOpen.value = false
|
||||
return
|
||||
}
|
||||
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
|
||||
if (pendingSkill.value && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
pendingSkill.value = null
|
||||
return
|
||||
}
|
||||
@@ -713,6 +727,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
skillOpen.value = false
|
||||
return
|
||||
}
|
||||
@@ -736,6 +751,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
return
|
||||
@@ -859,9 +875,10 @@ async function handleSend() {
|
||||
if (snapshotSpans.length) {
|
||||
pendingMentionSpans.value = snapshotSpans
|
||||
}
|
||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象。
|
||||
// I5:按错误内容分类映射友好文案,原始错误串留在 {msg} 细节里不丢排查线索
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
|
||||
emit('error', { msg: t(SEND_ERR_KEY[classifySendError(msg)], { msg }), type: 'error' })
|
||||
}
|
||||
await nextTick()
|
||||
emit('sent')
|
||||
@@ -902,11 +919,65 @@ function clearInput(): void {
|
||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||
}
|
||||
|
||||
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
||||
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
|
||||
* chip(与队列原地编辑只保 text 同一取舍)。清 mention 区间/浮层态,光标置尾并聚焦。 */
|
||||
function fillDraft(text: string, skill: SkillInfo | null = null): void {
|
||||
pendingSkill.value = skill
|
||||
inputText.value = text
|
||||
pendingMentionSpans.value = []
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
const el = inputEl.value
|
||||
if (el) {
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 聚焦输入框 */
|
||||
function focus(): void {
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
|
||||
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
||||
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
|
||||
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
|
||||
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
|
||||
watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
if (ov) setConvDraft(ov, inputText.value)
|
||||
inputText.value = getConvDraft(nv)
|
||||
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
||||
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
||||
pendingMentionSpans.value = []
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
skillOpen.value = false
|
||||
// 恢复文本会触发输入框自身 watch 重算联想(文本以 @ 结尾时按旧光标位误拉起浮层),
|
||||
// nextTick 在该 watch 之后统一收口,并按恢复文本重算输入框高度
|
||||
nextTick(() => {
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
autoResize()
|
||||
})
|
||||
})
|
||||
|
||||
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
|
||||
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
|
||||
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
|
||||
rate_limit: 'aiChat.sendErrRateLimit',
|
||||
quota: 'aiChat.sendErrQuota',
|
||||
network: 'aiChat.sendErrNetwork',
|
||||
other: 'aiChat.toastSendFail',
|
||||
}
|
||||
|
||||
/**
|
||||
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
|
||||
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||
@@ -923,6 +994,7 @@ defineExpose({
|
||||
focus,
|
||||
startEditFocus,
|
||||
clearInput,
|
||||
fillDraft,
|
||||
hasUnsentInput,
|
||||
sendExamplePrompt,
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
class="ai-conv-item"
|
||||
:class="{
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--switching': conv.id === pendingSwitchId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
'ai-conv-item--pinned': conv.pinned,
|
||||
}"
|
||||
@@ -49,6 +50,8 @@
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -102,8 +105,8 @@
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="beginSwitch(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<input
|
||||
@@ -111,8 +114,7 @@
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keyup.enter="commitRename(conv.id)"
|
||||
@keyup.esc="cancelRename"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -132,6 +134,8 @@
|
||||
><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -171,13 +175,15 @@
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="beginSwitch(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -235,6 +241,8 @@
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
|
||||
import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
import type { AiConversationSummary } from '../../api/types'
|
||||
|
||||
@@ -302,6 +310,20 @@ function cancelRename() {
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
|
||||
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
|
||||
function onRenameKeydown(e: KeyboardEvent, convId: string) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename(convId)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||
const actionsOpenId = ref<string | null>(null)
|
||||
|
||||
@@ -321,13 +343,31 @@ function onActionsOutsideClick(e: MouseEvent) {
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
|
||||
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
|
||||
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
|
||||
const pendingSwitchId = ref<string | null>(null)
|
||||
function beginSwitch(id: string) {
|
||||
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
|
||||
if (id === store.state.activeConversationId) {
|
||||
void store.switchConversation(id)
|
||||
return
|
||||
}
|
||||
pendingSwitchId.value = id
|
||||
void store.switchConversation(id).finally(() => {
|
||||
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
|
||||
if (pendingSwitchId.value === id) pendingSwitchId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
|
||||
beginSwitch(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
@@ -370,6 +410,30 @@ function ensureActiveVisible(): void {
|
||||
})
|
||||
}
|
||||
|
||||
// ── V1: 跨会话待审批徽标 ──
|
||||
// convPendingApprovals Map 本体非响应式(aiShared 注明渲染只依赖投影数组),故 computed 读 Map 的
|
||||
// 同时锚定三类响应式源触发重算:
|
||||
// - state.pendingApprovals(活跃会话投影,审批增删/切换恢复整体替换时必变)→ 活跃会话即时刷新;
|
||||
// - conversations 列表(新建/删除/排序刷新);
|
||||
// - pendingTick 2.5s 轮询兜底:后台会话新增/解除挂起不触碰任何响应式源(aiShared 只写裸 Map),
|
||||
// 轻量周期重算补偿(徽标属弱实时信息,秒级延迟可接受)。
|
||||
const pendingTick = ref(0)
|
||||
let pendingPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pendingCountByConv = computed(() => {
|
||||
void store.state.pendingApprovals.length
|
||||
void store.state.conversations.length
|
||||
void pendingTick.value
|
||||
const out = new Map<string, number>()
|
||||
for (const [convId, arr] of convPendingApprovals) {
|
||||
if (arr.length > 0) out.set(convId, arr.length)
|
||||
}
|
||||
return out
|
||||
})
|
||||
function pendingCountOf(convId: string): number {
|
||||
return pendingCountByConv.value.get(convId) ?? 0
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
@@ -447,10 +511,14 @@ function onResizeStart(e: MouseEvent) {
|
||||
onMounted(() => {
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
|
||||
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onActionsOutsideClick)
|
||||
// 释放待审批徽标轮询计时器
|
||||
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
|
||||
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
@@ -612,6 +680,12 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-sidebar-active);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -676,6 +750,21 @@ body.ai-sidebar-resizing * {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
.ai-conv-item-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
<template>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更)。
|
||||
条件:pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
点继续 → ai_continue_loop(后端续 max_iterations 轮)→ 卡片隐藏(等后端事件)。
|
||||
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
||||
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
||||
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
||||
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
||||
<div class="ai-max-rounds-actions">
|
||||
<button
|
||||
class="ai-max-rounds-btn ai-max-rounds-btn--continue"
|
||||
:disabled="maxRoundsActing"
|
||||
@click="handleContinueLoop"
|
||||
>{{ $t('aiChat.continueLoop') }}</button>
|
||||
<button
|
||||
class="ai-max-rounds-btn ai-max-rounds-btn--stop"
|
||||
:disabled="maxRoundsActing"
|
||||
@click="handleStopLoop"
|
||||
>{{ $t('aiChat.stopLoop') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { pendingMaxRounds } from '../../composables/ai/useAiPendingState'
|
||||
import { aiApi } from '../../api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// F-260620 根治(达 max 挂起无声卡死):同 DirAuthDialog,去 streaming 依赖。
|
||||
// 达 max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating 保持 true)发生在多轮迭代后,
|
||||
// 耗时长,130s 流式看门狗极易在迭代间隙超时清 streaming → 卡片因 streaming=false 不弹 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。达 max 比 DirAuthAuth 更易触发(必经多轮)。去 streaming,
|
||||
// isGenerating(conv) 单条件覆盖挂起态(达 max 时 generatingConvs 含 conv,useAiEvents handleEvent 对非完成/错误事件 add)。
|
||||
// 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
|
||||
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
|
||||
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
|
||||
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
|
||||
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
|
||||
// 理论不并存,此处宽放以防边界)。
|
||||
const isViewingGeneratingState = computed(() => {
|
||||
const cs = getConvState(store.state.activeConversationId)
|
||||
if (cs === 'generating' || cs === 'compressed') return true
|
||||
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
|
||||
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
|
||||
return isViewingGenerating.value
|
||||
})
|
||||
|
||||
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
||||
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
||||
// maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件
|
||||
// (continueLoop→新一轮 AiAgentRound;stopLoop→AiCompleted)自然清卡片。
|
||||
const maxRoundsActing = ref(false)
|
||||
const showMaxRoundsCard = computed(() =>
|
||||
!!pendingMaxRounds.value
|
||||
&& pendingMaxRounds.value === store.state.activeConversationId
|
||||
&& isViewingGeneratingState.value,
|
||||
)
|
||||
|
||||
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
||||
const maxRoundsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
||||
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
||||
async function handleContinueLoop(): Promise<void> {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
maxRoundsActing.value = true
|
||||
try {
|
||||
await aiApi.continueLoop(convId)
|
||||
} catch (e) {
|
||||
maxRoundsActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 点停止:调 ai_stop_loop。后端复位 generating + emit AiCompleted,useAiEvents 据此
|
||||
* 清 pendingMaxRounds。IPC 失败回滚 acting。 */
|
||||
async function handleStopLoop(): Promise<void> {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
maxRoundsActing.value = true
|
||||
try {
|
||||
await aiApi.stopLoop(convId)
|
||||
} catch (e) {
|
||||
maxRoundsActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.stopLoopFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
|
||||
watch(() => pendingMaxRounds.value, (v) => {
|
||||
if (!v) maxRoundsActing.value = false
|
||||
})
|
||||
|
||||
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
||||
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
||||
watch(showMaxRoundsCard, async (show) => {
|
||||
if (!show) return
|
||||
await nextTick()
|
||||
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-max-rounds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-max-rounds-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-max-rounds-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-max-rounds-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-max-rounds-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-max-rounds-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-max-rounds-btn--continue {
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
}
|
||||
.ai-max-rounds-btn--continue:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-max-rounds-btn--stop {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-max-rounds-btn--stop:hover:not(:disabled) {
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
@@ -540,16 +541,18 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
|
||||
|
||||
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
|
||||
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
|
||||
// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
|
||||
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
// msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
|
||||
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
|
||||
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
|
||||
`.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
|
||||
)
|
||||
if (!bubble) {
|
||||
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { default: html2canvas } = await import('html2canvas')
|
||||
const { default: html2canvas } = await import('html2canvas-pro')
|
||||
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) throw new Error('toBlob failed')
|
||||
@@ -574,6 +577,72 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
|
||||
// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
|
||||
// <pre><code> 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
|
||||
// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
|
||||
// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
|
||||
// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
|
||||
const _decoCache = new Map<string, string>()
|
||||
const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
if (hit !== undefined) return hit
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
|
||||
const out = raw.replace(
|
||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
|
||||
)
|
||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||
_decoCache.set(raw, out)
|
||||
return out
|
||||
}
|
||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||
function renderMdDecorated(raw: string): string {
|
||||
return decorateCodeHtml(renderMd(raw))
|
||||
}
|
||||
|
||||
// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
|
||||
const decoratedStreamingBlocks = computed(() =>
|
||||
streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
|
||||
)
|
||||
|
||||
/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
|
||||
async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
|
||||
const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
|
||||
// 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
|
||||
// execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
ok = true
|
||||
}
|
||||
} catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '已复制' : '复制失败'
|
||||
btn.classList.add(ok ? 'is-ok' : 'is-err')
|
||||
setTimeout(() => {
|
||||
btn.textContent = prev
|
||||
btn.classList.remove('is-ok', 'is-err')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
// ── 错误气泡操作入口 ──
|
||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||||
@@ -710,6 +779,18 @@ function shouldCollapseLong(msg: AiMessage): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
|
||||
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
|
||||
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
|
||||
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
|
||||
// 展开态仍复用既有 aiChat.collapse 键。
|
||||
const CHAR_PER_TOKEN_EST = 2.5
|
||||
function collapsedLongLabel(msg: AiMessage): string {
|
||||
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
|
||||
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
|
||||
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
|
||||
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
|
||||
@@ -828,6 +909,8 @@ type RenderItem =
|
||||
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
|
||||
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
|
||||
| { kind: 'timesep'; key: string; ts: number }
|
||||
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
|
||||
| { kind: 'agentFin'; key: string; card: AgentFinishedCard }
|
||||
|
||||
/**
|
||||
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
|
||||
@@ -964,9 +1047,36 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
}
|
||||
}
|
||||
flushBucket()
|
||||
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
|
||||
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
|
||||
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
|
||||
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
|
||||
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// ── S1 子代理完成卡(AiAgentFinished) ──
|
||||
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
|
||||
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
|
||||
function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
|
||||
if (status === 'truncated') return 'warn'
|
||||
if (status === 'failed') return 'err'
|
||||
return 'ok'
|
||||
}
|
||||
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
|
||||
function agentFinStatusText(status: AgentFinishedCard['status']): string {
|
||||
if (status === 'truncated') return t('ai.agentFinished.truncated')
|
||||
if (status === 'failed') return t('ai.agentFinished.failed')
|
||||
return t('ai.agentFinished.completed')
|
||||
}
|
||||
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
|
||||
// 全文经 title hover 兜底不丢信息。
|
||||
function agentFinSummary(summary: string): string {
|
||||
const s = (summary || '').replace(/\s+/g, ' ').trim()
|
||||
return s.length > 160 ? s.slice(0, 160) + '…' : s
|
||||
}
|
||||
|
||||
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
|
||||
const traceNav = computed(() =>
|
||||
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
|
||||
@@ -1025,7 +1135,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<!-- Messages -->
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
|
||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||||
<EmptyState
|
||||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||||
@@ -1085,7 +1195,7 @@ defineExpose({
|
||||
<div
|
||||
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMd(m.content)"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
></div>
|
||||
</template>
|
||||
<ToolCardList
|
||||
@@ -1107,6 +1217,18 @@ defineExpose({
|
||||
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
||||
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
|
||||
轻量细条范式,非消息数据不进气泡 -->
|
||||
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
|
||||
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
|
||||
<span class="ai-agent-fin-dot"></span>
|
||||
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
|
||||
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
|
||||
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
|
||||
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
|
||||
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
|
||||
<span>{{ formatAbsolute(item.ts) }}</span>
|
||||
@@ -1164,7 +1286,7 @@ defineExpose({
|
||||
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
||||
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
</div>
|
||||
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||||
<MessageItem
|
||||
@@ -1194,7 +1316,7 @@ defineExpose({
|
||||
@click="toggleJson(item.msg.id)"
|
||||
><pre>{{ item.msg.content }}</pre></div>
|
||||
<div
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0)"
|
||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
|
||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||
@@ -1203,18 +1325,18 @@ defineExpose({
|
||||
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
||||
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
||||
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
|
||||
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0">
|
||||
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||
</template>
|
||||
<!-- 完成/历史:整段v-html(不变) -->
|
||||
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||||
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) -->
|
||||
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
|
||||
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
|
||||
<button
|
||||
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
|
||||
class="ai-msg-bubble-toggle"
|
||||
@click.stop="toggleLong(item.msg.id)"
|
||||
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button>
|
||||
>{{ shouldCollapseLong(item.msg) ? collapsedLongLabel(item.msg) : $t('aiChat.collapse') }}</button>
|
||||
<button
|
||||
v-if="item.msg.content"
|
||||
class="ai-copy-btn ai-copy-btn--ai"
|
||||
@@ -1664,6 +1786,45 @@ defineExpose({
|
||||
right: 30px;
|
||||
}
|
||||
|
||||
/* ── R4 代码块复制按钮:节点经 v-html 注入,非 scoped 受控,经模板锚点 .ai-md :deep 命中。
|
||||
悬浮显示(代码块 hover 才现,右上角);is-ok/is-err 为复制反馈态(常显 1.2s 后由 JS 还原)。 ── */
|
||||
.ai-md :deep(.md-code-block) {
|
||||
position: relative;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-text-dim);
|
||||
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-md :deep(.md-code-block:hover .md-code-copy) {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy:hover) {
|
||||
color: var(--df-accent);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-ok) {
|
||||
opacity: 1;
|
||||
color: var(--df-success, #4caf50);
|
||||
border-color: var(--df-success, #4caf50);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-err) {
|
||||
opacity: 1;
|
||||
color: var(--df-danger);
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||
.ai-msg-actions {
|
||||
display: flex;
|
||||
@@ -2092,4 +2253,62 @@ defineExpose({
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
|
||||
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
|
||||
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
|
||||
.ai-agent-fin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-agent-fin-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn {
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-dot {
|
||||
background: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err {
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-dot {
|
||||
background: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-label {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-agent-fin-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-status {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-status {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-agent-fin-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,7 +187,7 @@ const showHistory = ref(false)
|
||||
const selectedFile = ref('')
|
||||
const diffContent = ref('')
|
||||
const diffLoading = ref(false)
|
||||
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile 失败)
|
||||
// 行内错误提示:loadError 主区展示(loadStatus 失败),actionError 分支条下方展示(loadMore/selectFile/切分支/提交详情 失败)
|
||||
const loadError = ref('')
|
||||
const actionError = ref('')
|
||||
// 请求序号守卫:moduleId 切换与 refreshKey 刷新双触发源并发时,旧响应晚到不覆盖新模块数据
|
||||
@@ -402,12 +402,14 @@ async function onSwitchBranch() {
|
||||
}
|
||||
showBranchPicker.value = true
|
||||
branchLoading.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
const res = await moduleApi.listBranches(props.moduleId)
|
||||
branchList.value = res.branches
|
||||
currentBranch.value = res.current
|
||||
} catch {
|
||||
} catch (e) {
|
||||
branchList.value = []
|
||||
actionError.value = String(e)
|
||||
} finally {
|
||||
branchLoading.value = false
|
||||
}
|
||||
@@ -479,11 +481,13 @@ async function selectCommit(c: CommitItem) {
|
||||
// 切换提交 → 清空右侧宽预览,避免残留上一个提交文件的 diff 与当前提交错位
|
||||
emit('select-file', '', '')
|
||||
commitDetailLoading.value = true
|
||||
actionError.value = ''
|
||||
try {
|
||||
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
|
||||
commitDetail.value = res
|
||||
} catch {
|
||||
} catch (e) {
|
||||
commitDetail.value = null
|
||||
actionError.value = String(e)
|
||||
} finally {
|
||||
commitDetailLoading.value = false
|
||||
}
|
||||
|
||||
@@ -132,10 +132,11 @@ watch(() => props.view, () => nextTick(measureDesc))
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
/* 阶段色对齐 ProjectDetail 语义 token:design=info / testing=warning / release=success */
|
||||
.stage-design { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.stage-testing { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.stage-release { background: var(--df-success-bg); color: var(--df-success); }
|
||||
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
|
||||
.stage-dim { background: color-mix(in srgb, var(--df-text-dim) 15%, transparent); color: var(--df-text-dim); }
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
<span class="setting-desc">{{ $t('settings.descApprovalTimeout') }}</span>
|
||||
</div>
|
||||
<!-- 文件上传服务端点:配置后聊天图片上传到用户自己的文件服务转公开 URL;留空则图片本地 base64 内嵌。
|
||||
与 fileUpload.ts 的读取约定同 key(df-file-upload-base),blur/enter 提交即落库生效 -->
|
||||
<SettingRow ref="rowUploadBase" :label="$t('settings.labelFileUploadBase')" :desc="$t('settings.descFileUploadBase')">
|
||||
与 fileUpload.ts 的读取约定同 key(df-file-upload-base),blur/enter 提交即落库生效。
|
||||
upload-row 整行堆叠(对齐 prompt-row 先例):原横排 280px 最小宽在窄列强挤 label 致逐字竖排 -->
|
||||
<SettingRow ref="rowUploadBase" class="upload-row" :label="$t('settings.labelFileUploadBase')" :desc="$t('settings.descFileUploadBase')">
|
||||
<input
|
||||
v-model="fileUploadBase"
|
||||
class="setting-input upload-base-input"
|
||||
@@ -264,9 +265,17 @@ function onApprovalTimeoutChange() {
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* 文件上传端点行:整行堆叠——label/desc 在上,输入独立一行拉满(对齐 prompt-row 先例)。
|
||||
窄列不再 280px 强制最小宽挤压 label 逐字竖排 */
|
||||
.upload-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* 端点输入走等宽字体(URL 逐字符核对更易读),对齐 AllowedDirsPanel 路径输入风格 */
|
||||
.upload-base-input {
|
||||
min-width: 280px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
}
|
||||
/* 自定义提示词行:整行堆叠,textarea 独立一行从左到右拉满。
|
||||
|
||||
@@ -481,6 +481,8 @@ async function fetchModels() {
|
||||
})
|
||||
// 手动优先:探测结果合并已存在的 UserSet 标注——用户手动改过的能力不被「测试连接」覆盖
|
||||
// (对齐后端 ai_fetch_models 合并逻辑:probe_source==UserSet 保留手动能力维度)。
|
||||
// enabled/weight 同理保留旧值:重拉只是刷新能力维度,用户已关掉的模型/调过的权重
|
||||
// 属用户配置意图,被探测默认值复位等于静默丢配置(走查 P1-2)。
|
||||
providerForm.models = models.map(m => {
|
||||
const old = providerForm.models.find(o => o.model_id === m.model_id)
|
||||
const isManual = old?.probe_source === 'user_set'
|
||||
@@ -489,6 +491,8 @@ async function fetchModels() {
|
||||
modalities: isManual ? [...old.modalities] : [...m.modalities],
|
||||
capabilities: isManual ? [...old.capabilities] : [...m.capabilities],
|
||||
probe_source: isManual ? 'user_set' : m.probe_source,
|
||||
enabled: old ? old.enabled : m.enabled,
|
||||
weight: old ? old.weight : m.weight,
|
||||
}
|
||||
})
|
||||
emit('toast', t('settings.toastFetchOk', { count: models.length }), 'info')
|
||||
@@ -600,7 +604,9 @@ async function setDefaultProvider(id: string) {
|
||||
// 重建 per_provider caps,下条消息即按新配置 acquire。
|
||||
// 不走 saveProvider(全量 INSERT OR REPLACE 会触发 R-PD-1 密钥迁移分支,与 c 轻量 UPDATE 路径冲突)。
|
||||
// ============================================================
|
||||
let _poolTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 计时器按 provider id 分键:单例计时器下 300ms 内先后改两家权重,B 的变更会清掉
|
||||
// A 尚未落库的保存,先改的一方静默丢失(走查 P2-8)。
|
||||
const _poolTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/**
|
||||
* toggle 即时生效(开关语义=立即入/出池,无 debounce 必要;但失败需回滚视图)。
|
||||
@@ -628,8 +634,11 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
|
||||
const clamped = Math.min(100, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 50))
|
||||
const prev = p.weight
|
||||
p.weight = clamped
|
||||
if (_poolTimer) clearTimeout(_poolTimer)
|
||||
_poolTimer = setTimeout(async () => {
|
||||
// 仅重置同一 provider 的待发计时;跨 provider 互不干扰
|
||||
const prevTimer = _poolTimers.get(p.id)
|
||||
if (prevTimer) clearTimeout(prevTimer)
|
||||
_poolTimers.set(p.id, setTimeout(async () => {
|
||||
_poolTimers.delete(p.id)
|
||||
try {
|
||||
await aiApi.updateProviderPool(p.id, p.enabled !== false, clamped)
|
||||
emit('toast', t('settings.toastPoolUpdateOk'), 'info')
|
||||
@@ -638,7 +647,7 @@ function onPoolWeightChange(p: AiProviderConfig, raw: number) {
|
||||
console.error(t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), e)
|
||||
emit('toast', t('settings.toastPoolUpdateFail', { msg: errMsg(e) }), 'error')
|
||||
}
|
||||
}, 300)
|
||||
}, 300))
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
@@ -654,7 +663,7 @@ defineExpose({
|
||||
loadProviders,
|
||||
isFormDirty,
|
||||
closeProviderForm,
|
||||
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
||||
clearPoolTimer() { for (const t of _poolTimers.values()) clearTimeout(t); _poolTimers.clear() },
|
||||
})
|
||||
|
||||
// 阶段3 UX 重构:本面板改为 ai-model 类懒挂载,不再由 Settings.vue onMounted 调用,
|
||||
|
||||
@@ -20,18 +20,21 @@
|
||||
|
||||
/* ===== 设置行容器(表单型 Panel 共用) ===== */
|
||||
.general-settings { display: flex; flex-direction: column; }
|
||||
/* 窄列(设置抽屉/面板窄态)三防线:gap 隔开 info 与 control;info min-width:0 允许收缩、
|
||||
label/desc 文本按常规断行(多词换行,不逐字竖排);control 不被压缩挤扁 */
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.setting-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
|
||||
.setting-control { display: flex; align-items: center; }
|
||||
.setting-control { display: flex; align-items: center; flex-shrink: 0; }
|
||||
|
||||
/* ===== 控件(5/5 Panel 重复) ===== */
|
||||
/* 控件统一基准(padding/border/radius/font 对齐),各 type 仅差异化必要属性 */
|
||||
|
||||
@@ -271,7 +271,7 @@ function confirmDelete() {
|
||||
}
|
||||
.tbb-toast--success { border-color: color-mix(in srgb, var(--df-success) 50%, transparent); color: var(--df-success); }
|
||||
.tbb-toast--warning { border-color: color-mix(in srgb, var(--df-warning) 50%, transparent); color: var(--df-warning); }
|
||||
.tbb-toast--error { border-color: rgba(240, 101, 101, 0.5); color: var(--df-danger); }
|
||||
.tbb-toast--error { border-color: color-mix(in srgb, var(--df-danger) 50%, transparent); color: var(--df-danger); }
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -29,8 +29,14 @@ export default {
|
||||
notRetryable: 'This error cannot be resolved by retrying. Please check the configuration.',
|
||||
streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
|
||||
streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
|
||||
// Light-convergence notice when the watchdog probe reports the backend already finished (only the closing event was lost)
|
||||
streamFinishedConverged: 'The response was interrupted during finalization and has been converged.',
|
||||
// First-time notice when the watchdog probe finds the backend loop alive/auto-resumed (only the first probe posts; subsequent re-arms stay silent)
|
||||
streamAutoResuming: 'The response stalled and is auto-resuming (no action needed)...',
|
||||
// UX-2025-04 / CR-30-2 / decision a1: system notice bubble after mid-stream failure preserve (frontend mirrors backend session.messages)
|
||||
responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.',
|
||||
// Backend auto-finalized after reaching the max rounds (not a network interruption; reuses the incomplete bubble with distinct semantics)
|
||||
responseIncompleteMaxRounds: 'Maximum rounds reached. The AI has finalized this round with the results so far; continue the conversation to have it refine and complete the answer.',
|
||||
queueFull: 'Send queue is full (max {limit} items)',
|
||||
forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
|
||||
forceSendBtn: 'Force Send',
|
||||
@@ -73,5 +79,14 @@ export default {
|
||||
stepRunning: 'running {name}',
|
||||
stepPending: '⏳ {n} pending approval',
|
||||
},
|
||||
// S1 sub-agent finished card (AiAgentFinished): lightweight terminal-state card at the
|
||||
// end of the parent conversation message flow
|
||||
agentFinished: {
|
||||
title: 'Sub-agent',
|
||||
completed: 'completed',
|
||||
truncated: 'truncated',
|
||||
failed: 'failed',
|
||||
meta: '{rounds} rounds · {tokens} tokens',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ export default {
|
||||
queueSave: 'Save',
|
||||
queueCancel: 'Cancel',
|
||||
queueSendNow: 'Send now',
|
||||
// 3c UX batch addition (I4 queue backfill: pull a queued item back into the input box)
|
||||
queueBackfill: 'Backfill',
|
||||
|
||||
// ── Skill ──
|
||||
clearSkill: 'Clear skill',
|
||||
@@ -194,7 +196,7 @@ export default {
|
||||
|
||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||
searchFilesBlocked: 'search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.',
|
||||
searchFilesBlocked: "search_files only works inside authorized dirs. Use {'@'}[project] to reference or provide an absolute path.",
|
||||
// /skill hot reload (ai_reload_skills IPC) success toast
|
||||
skillReloaded: 'Skills reloaded',
|
||||
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
||||
@@ -276,5 +278,11 @@ export default {
|
||||
goalsCount: 'Goals ({n})',
|
||||
none: 'None',
|
||||
projectLabel: "{'@'}Projects",
|
||||
|
||||
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
||||
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
||||
sendErrRateLimit: 'Rate limit reached, please retry later (error: {msg})',
|
||||
sendErrQuota: 'AI quota or balance insufficient, check your provider account (error: {msg})',
|
||||
sendErrNetwork: 'Network error or timeout, check your connection and retry (error: {msg})',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,8 +29,14 @@ export default {
|
||||
notRetryable: '此错误无法通过重试解决,请检查配置',
|
||||
streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
|
||||
streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
||||
// 看门狗探测到后端已正常收敛(仅收尾事件丢失)时补的轻收敛提示,非错误语气
|
||||
streamFinishedConverged: '响应在收尾阶段中断,已自动完成收敛。',
|
||||
// 看门狗超时探测到后端 loop 活着/已自动续跑时的首次提示(仅首次补,后续静默重挂不刷屏)
|
||||
streamAutoResuming: '响应出现停滞,正在自动恢复(无需手动操作)…',
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文后的系统提示气泡(前端镜像后端 session.messages)
|
||||
responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。',
|
||||
// 达最大轮次后端自动收尾(非网络中断,复用 incomplete 气泡但语义独立)
|
||||
responseIncompleteMaxRounds: '已达最大轮次,AI 已自动收尾本轮结果;可继续对话让 AI 补充完善',
|
||||
queueFull: '待发送队列已满(最多 {limit} 条)',
|
||||
forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
|
||||
forceSendBtn: '强制发送',
|
||||
@@ -73,5 +79,13 @@ export default {
|
||||
stepRunning: '正在 {name}',
|
||||
stepPending: '⏳ {n} 项待审批',
|
||||
},
|
||||
// S1 子代理完成卡(AiAgentFinished):父会话消息流末尾的子代理终态轻量卡
|
||||
agentFinished: {
|
||||
title: '子代理',
|
||||
completed: '已完成',
|
||||
truncated: '已截断',
|
||||
failed: '失败',
|
||||
meta: '{rounds} 轮 · {tokens} tokens',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ export default {
|
||||
queueSave: '保存',
|
||||
queueCancel: '取消',
|
||||
queueSendNow: '立即发送',
|
||||
// 3c UX 批追加(I4 队列回填:取出排队项填回输入框重新编辑)
|
||||
queueBackfill: '回填',
|
||||
|
||||
// ── 技能 ──
|
||||
clearSkill: '取消技能',
|
||||
@@ -195,7 +197,7 @@ export default {
|
||||
|
||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||
searchFilesBlocked: 'search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径',
|
||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 {'@'}[项目] 引用或提供绝对路径",
|
||||
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
||||
skillReloaded: '技能已重新加载',
|
||||
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
||||
@@ -277,5 +279,11 @@ export default {
|
||||
goalsCount: '目标({n})',
|
||||
none: '无',
|
||||
projectLabel: "{'@'}项目",
|
||||
|
||||
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
||||
// {msg} 保留原始错误串供排查,不丢线索 ──
|
||||
sendErrRateLimit: '发送频率超限,请稍后重试(原始错误:{msg})',
|
||||
sendErrQuota: 'AI 服务额度或余额不足,请检查 Provider 账户(原始错误:{msg})',
|
||||
sendErrNetwork: '网络异常或请求超时,请检查网络后重试(原始错误:{msg})',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -520,7 +520,8 @@ onMounted(() => {
|
||||
.col-tool { min-width: 130px; }
|
||||
.col-risk { width: 56px; }
|
||||
.col-status { width: 80px; }
|
||||
.col-decided { width: 64px; }
|
||||
/* min-width 而非定宽:容纳最长标签「自动(信任)」不逐字竖排,超出由 .audit-table-wrap overflow:auto 兜底 */
|
||||
.col-decided { min-width: 88px; }
|
||||
.col-args { min-width: 200px; max-width: 320px; }
|
||||
.col-result { min-width: 220px; max-width: 360px; }
|
||||
/* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */
|
||||
@@ -613,6 +614,7 @@ onMounted(() => {
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
white-space: nowrap; /* 窄列防 CJK 逐字竖排(对齐 .status-tag) */
|
||||
}
|
||||
.decided-human { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||
.decided-auto { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
|
||||
@@ -873,10 +873,11 @@ onBeforeUnmount(() => {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
/* 四档统一 color-mix 派生自语义 token(对齐 Tasks.vue 列表同款,主题切换语义不变) */
|
||||
.priority-critical { background: color-mix(in srgb, var(--df-danger) 20%, transparent); color: var(--df-danger); }
|
||||
.priority-high { background: color-mix(in srgb, var(--df-warning) 20%, transparent); color: var(--df-warning); }
|
||||
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.priority-medium { background: color-mix(in srgb, var(--df-info) 20%, transparent); color: var(--df-info); }
|
||||
.priority-low { background: color-mix(in srgb, var(--df-text-dim) 20%, transparent); color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 面板 ===== */
|
||||
/* Problem4 ⑤ 宽屏两栏:左栏(描述+关联信息+时间戳) / 右栏(任务产出+工作流进度)。
|
||||
|
||||
+27
-4
@@ -371,6 +371,8 @@
|
||||
<option v-for="idea in store.ideas" :key="idea.id" :value="idea.id">{{ idea.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 创建失败内联错误条(UX走查 P1):红字条放表单下方,弹窗不关可直接改后重试 -->
|
||||
<div v-if="createError" class="modal-error">{{ createError }}</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" @click="confirmCreate" :disabled="submitting || !newTaskTitle.trim()">{{ $t('common.confirm') }}</button>
|
||||
@@ -577,6 +579,9 @@ const newTaskParentId = ref('')
|
||||
// 关联灵感(空串=不关联,与后端 idea_id 空串处理一致)
|
||||
const newTaskIdeaId = ref('')
|
||||
const submitting = ref(false)
|
||||
// 新建失败内联错误(UX走查 2026-10-03 P1):store.error 横幅渲染在页面流,被 modal-overlay
|
||||
// 全屏遮罩盖住不可见,失败时弹窗必须自带红字条反馈,且保持弹窗不关让用户改完重试
|
||||
const createError = ref('')
|
||||
|
||||
// 父任务选项 = 当前选中项目的顶层任务(无 parent_id;仅顶层可作父,1 级嵌套)
|
||||
const parentTaskOptions = computed(() =>
|
||||
@@ -821,6 +826,8 @@ function openCreateModal(parent?: TaskRecord) {
|
||||
newTaskBranch.value = ''
|
||||
newTaskPriority.value = 2
|
||||
newTaskIdeaId.value = ''
|
||||
// 每次打开清掉上一次的失败残留
|
||||
createError.value = ''
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
@@ -828,6 +835,7 @@ async function confirmCreate() {
|
||||
if (!newTaskProjectId.value) { showToast(t('tasks.err.selectProject'), 'error'); return }
|
||||
if (!newTaskTitle.value.trim()) return
|
||||
submitting.value = true
|
||||
createError.value = ''
|
||||
try {
|
||||
const r = await store.createTask({
|
||||
project_id: newTaskProjectId.value as ProjectId,
|
||||
@@ -840,7 +848,11 @@ async function confirmCreate() {
|
||||
// 父任务透传(空串=顶层任务,后端视为 None)
|
||||
parent_id: newTaskParentId.value || undefined,
|
||||
})
|
||||
if (!r) return
|
||||
if (!r) {
|
||||
// 失败:弹窗不关,内联透出错误(store.error 横幅在遮罩之下看不到;取具体错误,无则兜底文案)
|
||||
createError.value = store.error || t('tasks.err.createFailed')
|
||||
return
|
||||
}
|
||||
showCreateModal.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -934,6 +946,16 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.tasks { padding: 16px 20px 20px; }
|
||||
|
||||
/* 新建任务失败内联错误条(UX走查 P1):页面流 error-banner 被 modal 遮罩盖住,弹窗内自带红字条 */
|
||||
.modal-error {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-danger-bg, rgba(220, 38, 38, 0.08));
|
||||
color: var(--df-danger, #dc2626);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 列表/看板切换分段按钮 */
|
||||
.view-toggle { margin-left: auto; display: flex; gap: 0; }
|
||||
.seg-btn {
|
||||
@@ -1243,10 +1265,11 @@ onUnmounted(() => {
|
||||
border-radius: var(--df-radius-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
/* 四档统一 color-mix 派生自语义 token(对齐 high 档既有写法,主题切换语义不变) */
|
||||
.priority-critical { background: color-mix(in srgb, var(--df-danger) 20%, transparent); color: var(--df-danger); }
|
||||
.priority-high { background: color-mix(in srgb, var(--df-warning) 20%, transparent); color: var(--df-warning); }
|
||||
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.priority-medium { background: color-mix(in srgb, var(--df-info) 20%, transparent); color: var(--df-info); }
|
||||
.priority-low { background: color-mix(in srgb, var(--df-text-dim) 20%, transparent); color: var(--df-text-dim); }
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user