优化: 对话交互修复与界面完善

- 修输入法组词回车直发/分离窗误关/出错后按钮死锁
- 新建任务失败弹窗内联提示,会话切换乐观高亮
- 状态多选联动刷新不再冲空可见行
- 测试连接保留已配开关与权重,防抖按提供商分键
- 代码块复制/长回复折叠带量/队列回填/排队时钟
- 子代理完成卡渲染,死链卡片与引用清理
- 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
lxy
2026-10-03 15:35:18 +08:00
parent b4fafa0e9a
commit 2d43f90e50
20 changed files with 609 additions and 255 deletions
+76 -19
View File
@@ -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;
+75 -3
View File
@@ -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,
})
+96 -7
View File
@@ -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;
-180
View File
@@ -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>
+231 -12
View File
@@ -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>
+7 -3
View File
@@ -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
}
+4 -3
View File
@@ -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); }
+12 -3
View File
@@ -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 独立一行从左到右拉满。
+14 -5
View File
@@ -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 调用,
+5 -2
View File
@@ -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 仅差异化必要属性 */
+1 -1
View File
@@ -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>
+15
View File
@@ -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',
},
},
}
+9 -1
View File
@@ -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})',
},
}
+14
View File
@@ -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',
},
},
}
+9 -1
View File
@@ -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})',
},
}
+3 -1
View File
@@ -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); }
+4 -3
View File
@@ -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
View File
@@ -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;