修复: ai-chat 交互体验批(草稿备份/图片放大/目标完成态/淘汰边界条)

- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示
- 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾
- 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询
- 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚)
- 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
This commit is contained in:
lxy
2026-10-25 22:32:49 +08:00
parent 3caf2d047f
commit 62776a30f1
38 changed files with 3848 additions and 2778 deletions
@@ -1141,17 +1141,17 @@ pub async fn ai_conversation_set_pinned(
/// ///
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。 /// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。 /// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
// 跨端修复(走查批E):签名 Vec<String> → Vec<GoalEntry>。原实现把前端载荷逐条
// GoalEntry::new 重置 active,已完成目标(status="completed")的完成态被抹。
// 前端 GoalsPanel 同步改传完整 GoalEntry[](text/status,snake_case),反序列化缺
// status 时 serde default 补 active(见 mod.rs GoalEntry 定义),旧载荷兼容。
#[tauri::command] #[tauri::command]
pub async fn ai_update_conversation_goals( pub async fn ai_update_conversation_goals(
state: State<'_, AppState>, state: State<'_, AppState>,
conversation_id: String, conversation_id: String,
goals: Vec<String>, goals: Vec<crate::commands::ai::GoalEntry>,
) -> Result<(), String> { ) -> Result<(), String> {
// 前端传回的 string[] 转为 GoalEntry(假设全部 active) let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
.map(|g| crate::commands::ai::GoalEntry::new(g))
.collect();
let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
// 写 DB // 写 DB
state.ai_conversations state.ai_conversations
.update_field(&conversation_id, "pinned_goals", &goals_json) .update_field(&conversation_id, "pinned_goals", &goals_json)
@@ -1160,7 +1160,7 @@ pub async fn ai_update_conversation_goals(
// 同步内存 per_conv(若已加载) // 同步内存 per_conv(若已加载)
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
if let Some(conv) = session.per_conv.get_mut(&conversation_id) { if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
conv.pinned_goals = goal_entries; conv.pinned_goals = goals;
} }
Ok(()) Ok(())
} }
+4 -2
View File
@@ -2,7 +2,7 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types' import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, GoalEntry, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types'
export const aiApi = { export const aiApi = {
/** /**
@@ -387,8 +387,10 @@ export const aiApi = {
/** /**
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。 * 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。 * 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
* 传完整 GoalEntry[](含 status):后端按原样存储,降级 string[] 会被逐条
* GoalEntry::new 重置 active,已完成目标完成态被抹(2026-10-24 跨端修复)。
*/ */
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> { updateConversationGoals(conversationId: string, goals: GoalEntry[]): Promise<void> {
return invoke('ai_update_conversation_goals', { conversationId, goals }) return invoke('ai_update_conversation_goals', { conversationId, goals })
}, },
+6
View File
@@ -332,6 +332,9 @@ function onGlobalKeydown(e: KeyboardEvent) {
const msg = e instanceof Error ? e.message : String(e) const msg = e instanceof Error ? e.message : String(e)
showToast(t('aiChat.regenerateFailed', { msg }), 'error') showToast(t('aiChat.regenerateFailed', { msg }), 'error')
}) })
} else {
// 走查:生成中按键被 streaming 守卫静默吞掉,用户以为快捷键坏了;补一句轻提示
showToast(t('aiChat.generatingBusyHint'), 'warning')
} }
return return
} }
@@ -340,6 +343,9 @@ function onGlobalKeydown(e: KeyboardEvent) {
e.preventDefault() e.preventDefault()
if (!store.state.streaming) { if (!store.state.streaming) {
confirmClearChat() confirmClearChat()
} else {
// 走查:同 Ctrl+R——守卫吞键须有反馈
showToast(t('aiChat.generatingBusyHint'), 'warning')
} }
return return
} }
+12 -4
View File
@@ -48,8 +48,8 @@
</div> </div>
<!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块): <!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块):
尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染 --> 尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染;新行追加自动滚尾 -->
<div v-if="tc.status === 'running' && tc.streamLines?.length" class="ai-tool-stream"> <div v-if="tc.status === 'running' && tc.streamLines?.length" ref="streamEl" class="ai-tool-stream">
<div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div> <div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div>
<div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div> <div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div>
</div> </div>
@@ -187,7 +187,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue' import { computed, nextTick, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import {
shouldKeepOpen, shouldKeepOpen,
@@ -332,6 +332,14 @@ watch(() => props.tc.status, (s) => {
} }
}, { immediate: true }) }, { immediate: true })
/** 实时输出滚尾:流式行追加时输出区滚到底,最新一行恒可见(容器 160px 上限,超限即内滚) */
const streamEl = ref<HTMLElement | null>(null)
watch(() => props.tc.streamLines?.length, async () => {
await nextTick()
const el = streamEl.value
if (el) el.scrollTop = el.scrollHeight
})
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏); /** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */ * running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
const durationText = computed(() => { const durationText = computed(() => {
@@ -532,7 +540,7 @@ const visibleArgs = computed(() => {
max-height: 160px; overflow-y: auto; max-height: 160px; overflow-y: auto;
} }
.ai-tool-stream-line { .ai-tool-stream-line {
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono);
font-size: 11px; line-height: 1.5; font-size: 11px; line-height: 1.5;
color: var(--df-text-secondary); color: var(--df-text-secondary);
white-space: pre-wrap; word-break: break-all; white-space: pre-wrap; word-break: break-all;
-123
View File
@@ -1,123 +0,0 @@
<template>
<div class="ci-status" v-if="statuses.length > 0">
<div class="ci-header">
<span class="ci-title">{{ $t('aiChat.ciTitle') }}</span>
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
</div>
<div class="ci-list">
<a
v-for="check in statuses"
:key="check.name"
:href="check.url || undefined"
class="ci-item"
:class="'ci-item--' + check.status"
target="_blank"
rel="noopener"
>
<span class="ci-item-icon">{{ statusIcon(check.status) }}</span>
<span class="ci-item-name">{{ check.name }}</span>
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core'
const { t } = useI18n()
interface CheckStatus {
name: string
status: string
url: string | null
}
const props = defineProps<{
repoUrl: string
commitSha: string
}>()
const statuses = ref<CheckStatus[]>([])
const summaryClass = computed(() => {
const hasFailure = statuses.value.some(s => s.status === 'failure')
const hasPending = statuses.value.some(s => s.status === 'pending')
if (hasFailure) return 'ci-summary--fail'
if (hasPending) return 'ci-summary--pending'
return 'ci-summary--pass'
})
const summaryText = computed(() => {
const pass = statuses.value.filter(s => s.status === 'success').length
const fail = statuses.value.filter(s => s.status === 'failure').length
const pending = statuses.value.filter(s => s.status === 'pending').length
if (fail > 0) return t('aiChat.ciNFailed', { n: fail })
if (pending > 0) return t('aiChat.ciNPending', { n: pending })
return t('aiChat.ciNPassed', { n: pass })
})
function statusIcon(status: string): string {
switch (status) {
case 'success': return '✓'
case 'failure': return '✗'
case 'pending': return '⏳'
default: return '○'
}
}
onMounted(async () => {
try {
statuses.value = await invoke<CheckStatus[]>('get_commit_status', {
repoUrl: props.repoUrl,
commitSha: props.commitSha,
})
} catch {
statuses.value = []
}
})
</script>
<style scoped>
.ci-status {
border: 1px solid var(--df-border);
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-card);
font-size: 12px;
}
.ci-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.ci-title { color: var(--df-text); }
.ci-summary--pass { color: var(--df-success); }
.ci-summary--fail { color: var(--df-danger); }
.ci-summary--pending { color: var(--df-warning); }
.ci-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ci-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--df-bg);
border: 1px solid var(--df-border);
text-decoration: none;
color: var(--df-text);
}
.ci-item--success { border-color: var(--df-success); }
.ci-item--failure { border-color: var(--df-danger); }
.ci-item--pending { border-color: var(--df-warning); }
.ci-item-icon { font-size: 10px; font-weight: 500; }
.ci-item:hover { opacity: 0.8; }
</style>
+234 -67
View File
@@ -21,7 +21,7 @@
ref="inputEl" ref="inputEl"
v-model="inputText" v-model="inputText"
class="ai-input" class="ai-input"
:class="{ 'ai-input--editing': !!editingMsgId }" :class="{ 'ai-input--editing': !!editingMsgId, 'ai-input--dragover': dragHover }"
:placeholder="editingMsgId :placeholder="editingMsgId
? $t('aiChat.placeholderEditing') ? $t('aiChat.placeholderEditing')
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))" : (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
@@ -30,7 +30,12 @@
@beforeinput="onBeforeInputText" @beforeinput="onBeforeInputText"
@input="onInputText" @input="onInputText"
@paste="onPaste" @paste="onPaste"
@blur="onInputBlur"
@click="onCaretMove"
@keyup="onCaretMove"
@dragover="onDragOver" @dragover="onDragOver"
@dragenter="onDragEnter"
@dragleave="onDragLeave"
@drop="onDrop" @drop="onDrop"
></textarea> ></textarea>
@@ -168,6 +173,8 @@ import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeCo
import { extractImageUrlParts } from '../../composables/ai/utils' import { extractImageUrlParts } from '../../composables/ai/utils'
import { uploadFile } from '../../composables/ai/fileUpload' import { uploadFile } from '../../composables/ai/fileUpload'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
// 2026-10-19 拖拽/粘贴非图片文件落盘:ai_save_dropped_file IPC(后端 chat.rs:503)
import { invoke } from '@tauri-apps/api/core'
import SkillMention from './SkillMention.vue' import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue' import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue' import MentionPopover from './MentionPopover.vue'
@@ -210,13 +217,16 @@ const outboxOpen = ref(false)
// 约束: // 约束:
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。 // - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。 // - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。 // - 仅接受 image/* MIME;非图片文件 2026-10-19 起走 addAttachmentFile 落盘引用行
// (不再放行原生塞文本),纯文本粘贴仍走原生不拦。
// - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL // - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL
// (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。 // (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。
// pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。 // pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。
const MAX_PENDING_IMAGES = 6 const MAX_PENDING_IMAGES = 6
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限) const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
const pendingImages = ref<{ id: string; dataUrl?: string; url?: string; mediaType: string }[]>([]) // uploading=true 为上传中的占位 chip(url/dataUrl 未定,渲染 loading 态不入发送快照)
interface PendingImage { id: string; dataUrl?: string; url?: string; mediaType: string; uploading?: boolean }
const pendingImages = ref<PendingImage[]>([])
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */ /** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
function readImageFile(file: File): Promise<string> { function readImageFile(file: File): Promise<string> {
@@ -228,28 +238,40 @@ function readImageFile(file: File): Promise<string> {
}) })
} }
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。 */ /** 把 image File 加入 pendingImages;超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。
* 走查 P1:入列先同步 push「上传中」占位 chip(复用缩略图盒+loading 态),完成原位替换/失败移除,
* 治大图/慢网上传期间零反馈像「没贴上」;数量上限复核移到实际落定前——上传是异步窗口,两批
* 并发粘贴都能过入口检查而双双穿透,在落定点复核才治本。上传失败回退本地引用时轻提示告知。 */
async function addImageFile(file: File): Promise<void> { async function addImageFile(file: File): Promise<void> {
if (!file.type.startsWith('image/')) return if (!file.type.startsWith('image/')) return
if (file.size > MAX_IMAGE_BYTES) { if (file.size > MAX_IMAGE_BYTES) {
emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' }) emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' })
return return
} }
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
pendingImages.value.push({ id, mediaType: file.type, uploading: true })
// 占位 chip 原位落定(用户中途点 × 移除则静默丢弃);落定前复核上限(占位也计数)
const settle = (img: PendingImage): void => {
if (!pendingImages.value.some(p => p.id === id)) return
if (pendingImages.value.length >= MAX_PENDING_IMAGES) { if (pendingImages.value.length >= MAX_PENDING_IMAGES) {
removePendingImage(id)
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' }) emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
return return
} }
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` pendingImages.value[pendingImages.value.findIndex(p => p.id === id)] = img
}
try { try {
// 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。 // 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
// 未配置或失败回退 base64(dataUrl),不阻断图片输入。 // 未配置或失败回退 base64(dataUrl),不阻断图片输入。
const { url } = await uploadFile(file) const { url } = await uploadFile(file)
pendingImages.value.push({ id, url, mediaType: file.type }) settle({ id, url, mediaType: file.type })
} catch { } catch {
try { try {
const dataUrl = await readImageFile(file) const dataUrl = await readImageFile(file)
pendingImages.value.push({ id, dataUrl, mediaType: file.type }) settle({ id, dataUrl, mediaType: file.type })
emit('error', { msg: t('aiChat.imageUploadFallback'), type: 'info' })
} catch (e) { } catch (e) {
removePendingImage(id)
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' }) emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
console.error('[AI] 图片读取失败:', e) console.error('[AI] 图片读取失败:', e)
} }
@@ -316,6 +338,36 @@ async function insertTextFileDraft(path: string): Promise<void> {
} }
} }
/** 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治):
* 前端预拦 20MB(省一次 IPC,后端 chat.rs 同上限兜底)→ 字节经 ai_save_dropped_file
* IPC 落盘 attachments/(hash 去重+原名保真,media_store::store_attachment)→ 换回本机
* 绝对路径插引用行进草稿(与 I9 insertTextFileDraft 同款追加+光标回末尾手法)。
* AI 经 read_file 按需读取,与飞书文件入站同范式。引用行走 i18n 模板,`[附件]` 不带
* `:名` 结构且 mention span 仅由 selectMention 显式记录(无文本正则解析),不会误捕。 */
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 // 对齐后端 ai_save_dropped_file 上限
async function addAttachmentFile(file: File): Promise<void> {
if (file.size > MAX_ATTACHMENT_BYTES) {
emit('error', { msg: t('aiChat.attachmentTooLarge'), type: 'warning' })
return
}
try {
// data 传 JSON number 数组(后端 Vec<u8> 可反序列化)
const buf = await file.arrayBuffer()
const data = Array.from(new Uint8Array(buf))
const savedPath = await invoke<string>('ai_save_dropped_file', { fileName: file.name, data })
inputText.value += '\n' + t('aiChat.attachmentRefLine', { name: file.name, path: savedPath }) + '\n'
await nextTick()
inputEl.value?.focus()
const el = inputEl.value
if (el) el.selectionStart = el.selectionEnd = el.value.length
} catch (e) {
// 落盘失败(空文件/超限/IO 错):后端错误字符串入 {msg} 细节,不阻断后续文件
const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t('aiChat.attachmentSaveFailed', { msg }), type: 'error' })
console.error('[AI] 附件落盘失败:', file.name, e)
}
}
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */ /** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
async function onPickImage(): Promise<void> { async function onPickImage(): Promise<void> {
const selected = await open({ const selected = await open({
@@ -355,48 +407,102 @@ function removePendingImage(id: string): void {
pendingImages.value = pendingImages.value.filter(p => p.id !== id) pendingImages.value = pendingImages.value.filter(p => p.id !== id)
} }
/** 粘贴:ClipboardEvent 检测 image 类型条目,加入待发送列表(不插入文本占位) */ /** 粘贴:ClipboardEvent 收集全部 file 条目——图片走 addImageFile 进待发送列表,
* 非图片走 addAttachmentFile 落盘插引用行(2026-10-19 根治「发文件变一堆文本」);
* 剪贴板无文件(纯文本/字符串)不拦,原生插入行为不变。
* 走查:富文本「图+文」混合剪贴板(如网页图文一起复制)原实现只收文件、preventDefault
* 后把字符串条目静默丢弃,文案凭空丢失。现补收 text/plain 条目,拦下默认行为后手动
* 插回光标处(text/html 是同内容富文本重复源,不收防双插)。 */
async function onPaste(e: ClipboardEvent): Promise<void> { async function onPaste(e: ClipboardEvent): Promise<void> {
const items = e.clipboardData?.items const items = e.clipboardData?.items
if (!items) return if (!items) return
const files: File[] = [] const images: File[] = []
const attachments: File[] = []
const textChunks: string[] = []
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const it = items[i] const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) { // 字符串条目:只取 text/plain(走文件接管链时手动补插);getAsString 异步,包 Promise 收集
if (it.kind === 'string') {
if (it.type === 'text/plain') {
const s = await new Promise<string>((resolve) => { it.getAsString(resolve) })
if (s) textChunks.push(s)
}
continue
}
// kind==='file' 才是文件条目;MIME 以 File.type 为准
if (it.kind !== 'file') continue
const f = it.getAsFile() const f = it.getAsFile()
if (f) files.push(f) if (!f) continue
if (f.type.startsWith('image/')) images.push(f)
else attachments.push(f)
} }
} if (images.length === 0 && attachments.length === 0) return
if (files.length === 0) return // 含文件:阻止默认粘贴(防原生把文本文件内容/图片二进制塞进 textarea,病灶根因),逐个处理
// 含图片:阻止默认粘贴(避免把图片的二进制当文本塞进 textarea),逐个加入
e.preventDefault() e.preventDefault()
for (const f of files) await addImageFile(f) if (textChunks.length > 0) insertTextAtCursor(textChunks.join('\n'))
for (const f of images) await addImageFile(f)
for (const f of attachments) await addAttachmentFile(f)
} }
/** 拖拽进入:仅允许图片,preventDefault 让 drop 事件触发 */ /** 光标处插入文本并置尾(原生粘贴被 preventDefault 接管后补插文本条目用);
* 程序赋值不派发 input,mention span 须手动平移(与 applyListEdit 同款手法) */
function insertTextAtCursor(text: string): void {
const old = inputText.value
const el = inputEl.value
if (!el) {
inputText.value = old + text
return
}
const start = el.selectionStart ?? old.length
const end = el.selectionEnd ?? start
inputText.value = old.slice(0, start) + text + old.slice(end)
shiftMentionSpans(old, inputText.value)
nextTick(() => {
el.focus()
const pos = start + text.length
el.setSelectionRange(pos, pos)
autoResize()
})
}
/** 拖拽悬停:drag 项含文件即 preventDefault 让 drop 事件触发(copy/move 光标提示)。
* dragover 阶段 types 只给 'Files' 不给具体 MIME,文件(无论图片/非图片)统一接管;
* 2026-10-19 起非图片文件也走落盘引用,不再放行原生塞文本。 */
function onDragOver(e: DragEvent): void { function onDragOver(e: DragEvent): void {
if (!e.dataTransfer) return if (!e.dataTransfer) return
// 判断当前是否含 image/* 类型(copy/move 光标提示) const hasFiles = Array.from(e.dataTransfer.types).includes('Files')
const hasImage = Array.from(e.dataTransfer.types).includes('Files') if (hasFiles) {
if (hasImage) {
e.preventDefault() e.preventDefault()
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = 'copy'
} }
} }
/** 拖拽放下:取出图片 File,加入待发送 */ // 走查:拖拽悬停落点高亮——文件拖入输入框时描边提示可放;textarea 无子元素,
async function onDrop(e: DragEvent): Promise<void> { // dragenter/dragleave 成对触发无冒泡误清问题,离开/落下即撤
const files = e.dataTransfer?.files const dragHover = ref(false)
if (!files || files.length === 0) return function onDragEnter(e: DragEvent): void {
// 仅处理图片文件(非图片拖入走原生不拦,但这里 preventDefault 已无副作用) if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) dragHover.value = true
const images = Array.from(files).filter(f => f.type.startsWith('image/')) }
if (images.length === 0) return function onDragLeave(): void {
e.preventDefault() dragHover.value = false
for (const f of images) await addImageFile(f)
} }
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */ /** 拖拽放下:有文件即 preventDefault 接管——图片走 addImageFile,非图片走 addAttachmentFile。
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value) * 病灶:此前非图片直接 return 不拦,WebView2 原生行为把文本文件内容整段塞进输入框
* (2026-10-19 用户反馈「发送文件变成一堆文本」),现统一落盘为附件引用行。 */
async function onDrop(e: DragEvent): Promise<void> {
const files = e.dataTransfer?.files
dragHover.value = false
if (!files || files.length === 0) return
e.preventDefault()
for (const f of Array.from(files)) {
if (f.type.startsWith('image/')) await addImageFile(f)
else await addAttachmentFile(f)
}
}
/** 发送可用:有文本或已就绪图片(上传中占位不算,无 url/dataUrl 入列会产出空 image 片)或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.some(p => !p.uploading) || !!pendingSkill.value)
// 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红); // 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红);
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false, // 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
@@ -431,7 +537,11 @@ const currentModelSupportsVision = computed<boolean>(() => {
if (!chosen) return true if (!chosen) return true
return (chosen.modalities ?? []).includes('vision') return (chosen.modalities ?? []).includes('vision')
}) })
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value) // 走查:警示不只覆盖粘贴图——输入文本里的图片 URL 发送时同样转 image 片被非视觉模型忽略,
// 一并纳入(判定复用发送链同款 extractImageUrlParts,口径单一)
const showImageIgnoreWarn = computed(() =>
!currentModelSupportsVision.value
&& (pendingImages.value.length > 0 || extractImageUrlParts(inputText.value).length > 0))
// ── 技能 `/` 联想 ── // ── 技能 `/` 联想 ──
const skillOpen = ref(false) const skillOpen = ref(false)
@@ -498,6 +608,23 @@ function detectMentionTrigger() {
mentionStart.value = -1 mentionStart.value = -1
} }
// 走查:@ 浮层收口——textarea 失焦即关(浮层项点击已在 MentionPopover 用 mousedown.prevent
// 防「mousedown 先于 blur」竞态:焦点不离开 textarea,click 选择照常触发),点外部区域/Tab
// 离开不再悬挂浮层
function onInputBlur(): void {
mentionOpen.value = false
mentionStart.value = -1
}
// 走查:光标移出 @ 触发区间即关——方向键/点击移动光标不触发 input,detectMentionTrigger
// 不会被复检;keyup/click 后复核。浮层导航键(↑↓ 在 keydown 改高亮,keyup 复检会把
// mentionIndex 重置回 0)与提交键跳过,防导航失灵
function onCaretMove(e: Event): void {
if (!mentionOpen.value) return
if (e.type === 'keyup' && ['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'Tab'].includes((e as KeyboardEvent).key)) return
detectMentionTrigger()
}
function selectSkill(s: SkillInfo) { function selectSkill(s: SkillInfo) {
pendingSkill.value = s pendingSkill.value = s
inputText.value = '' inputText.value = ''
@@ -691,26 +818,25 @@ const enrichmentDetailText = computed(() => {
function dismissEnrichment() { function dismissEnrichment() {
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project') const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
if (projectSpans.length === 0) return if (projectSpans.length === 0) return
// 从 inputText 中移除所有 [@项目: xxx] 标记 // 走查:按 span 区间从后往前删——原实现循环内 text.replace,首个标记删除后文本变短,
// 后续 span.start 还是原始偏移,再 replace 可能匹配到错位内容误删相邻文本。
// span.start 是 selectMention 时刻的原始偏移,倒序删除不影响前方偏移,逐段精确裁掉。
let text = inputText.value let text = inputText.value
for (const span of projectSpans) { const spans = [...projectSpans].sort((a, b) => b.start - a.start)
const label = text.slice(span.start, span.start + span.length) for (const span of spans) {
// 替换标记 + 前后可能的空格为单个空格 // 标记两侧紧邻空白一并吞掉(对齐原 \s*…\s* → ' ' 语义),换成单个空格保持词距
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母), let start = span.start
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。 let end = span.start + span.length
const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`) while (start > 0 && /\s/.test(text[start - 1])) start--
text = text.replace(pattern, ' ').trim() while (end < text.length && /\s/.test(text[end])) end++
text = text.slice(0, start) + ' ' + text.slice(end)
} }
inputText.value = text inputText.value = text.trim()
// 清除 project 类型的 mentionSpan // 清除 project 类型的 mentionSpan
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project') pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
enrichmentExpanded.value = false enrichmentExpanded.value = false
} }
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。 // P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。 // 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
watch( watch(
@@ -922,12 +1048,8 @@ function handleKeydown(e: KeyboardEvent) {
selectSkill(filteredSkills.value[skillIndex.value]) selectSkill(filteredSkills.value[skillIndex.value])
return return
} }
if (e.key === 'Escape') { // (走查:此处原有一个 Escape 分支——上方 skillOpen+Escape 通用分支已先行消费并 return,
e.preventDefault() // 本分支不可达,删除。@ 浮层的同名分支可达,保留。)
e.stopPropagation()
skillOpen.value = false
return
}
} }
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通 // UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。 // 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
@@ -962,6 +1084,14 @@ function handleKeydown(e: KeyboardEvent) {
return return
} }
} }
// 走查:@ 实体引用浮层开着但无匹配时,Enter 原会穿透到下方发送分支把 "@xxx" 原文当消息
// 发出。对齐技能侧 skillNoMatchQuery 范式:不发送 + toast 提示;确要发原文先 Esc 关浮层。
if (mentionOpen.value && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
e.stopPropagation()
emit('error', { msg: t('aiChat.mentionNoMatchQuery', { q: mentionQuery.value.trim() }), type: 'warning' })
return
}
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。 // 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前; // 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。 // 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
@@ -990,8 +1120,8 @@ function handleKeydown(e: KeyboardEvent) {
} }
} }
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致, /** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致已统一 200。
* 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */ * 走查:resize:vertical 已随塌回根治移除,本上限即 auto-grow 唯一天花板) */
const INPUT_MAX_HEIGHT = 200 const INPUT_MAX_HEIGHT = 200
function autoResize() { function autoResize() {
@@ -1016,6 +1146,8 @@ async function handleSend() {
if (inputEl.value) inputEl.value.style.height = 'auto' if (inputEl.value) inputEl.value.style.height = 'auto'
try { try {
await store.editMessage(text) await store.editMessage(text)
// 编辑成功即消费草稿备份:cancel-edit 链的 clearInput 照旧清空,不回填编辑前的旧稿
editBackupDraft = null
emit('cancel-edit') emit('cancel-edit')
} catch (e) { } catch (e) {
console.error('[AI] 编辑失败:', e) console.error('[AI] 编辑失败:', e)
@@ -1029,18 +1161,20 @@ async function handleSend() {
return return
} }
const skill = pendingSkill.value const skill = pendingSkill.value
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用) // 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)。
if (!text && !skill && pendingImages.value.length === 0) return // 走查:上传中的占位 chip 不计入(无 url/dataUrl,入快照会产出空 image 片)
const settledImages = pendingImages.value.filter(p => !p.uploading)
if (!text && !skill && settledImages.length === 0) return
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮), // B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状 // 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) { if (!text && !skill && settledImages.length > 0 && !currentModelSupportsVision.value) {
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' }) emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
return return
} }
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。 // F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的 // 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。 // user 消息渲染多模态图,后端请求仍走 content 文本路径。
const snapshotImgs = pendingImages.value.slice() const snapshotImgs = settledImages
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。 // Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim() // 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。 // 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
@@ -1094,7 +1228,8 @@ async function handleSend() {
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿) // F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
persistDraftNow() persistDraftNow()
pendingSkill.value = null pendingSkill.value = null
pendingImages.value = [] // 走查:发送只消费已就绪图片,上传中的占位 chip 保留在列(落定后随下一条消息发送)
pendingImages.value = pendingImages.value.filter(p => p.uploading)
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步) // Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
pendingMentionSpans.value = [] pendingMentionSpans.value = []
skillOpen.value = false skillOpen.value = false
@@ -1111,9 +1246,9 @@ async function handleSend() {
console.error('[AI] 发送失败:', e) console.error('[AI] 发送失败:', e)
inputText.value = text inputText.value = text
pendingSkill.value = skill pendingSkill.value = skill
// 发送失败:回填图片供重试(快照原样还原) // 发送失败:回填图片供重试(快照原样还原;上传中占位已在列,追加不重复)
if (parts) { if (parts) {
pendingImages.value = snapshotImgs pendingImages.value = [...pendingImages.value, ...snapshotImgs]
} }
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐) // 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
if (snapshotSpans.length) { if (snapshotSpans.length) {
@@ -1144,6 +1279,11 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ── // ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
// 走查 P1:进入编辑态前的草稿备份(null=无编辑进行中/备份已消费)。
// startEditFocus 写入,cancelEdit 链回填,编辑成功发送时清空(照旧清空不回填)。
// 记 convId:取消可能由切会话触发,落盘归属须按备份时的会话判定(见 clearInput)
let editBackupDraft: { convId: string; text: string } | null = null
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */ /** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
function startEditFocus(msg: AiMessage): void { function startEditFocus(msg: AiMessage): void {
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换 // UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
@@ -1155,6 +1295,15 @@ function startEditFocus(msg: AiMessage): void {
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' }) emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
return return
} }
// 走查 P1:覆盖为消息原文前,先按既有 per-conv 草稿范式(内存 Map + 落盘镜像)备份当前
// 未发送草稿,取消编辑时回填——否则草稿被消息原文顶掉且 300ms 防抖把它落盘覆盖,
// 一次 Esc 就丢稿。已有备份(编辑态内再点另一条编辑)不覆盖,保住用户最初的草稿。
const convId = store.state.activeConversationId
if (editBackupDraft === null && convId) {
editBackupDraft = { convId, text: inputText.value }
setConvDraft(convId, inputText.value)
persistDraft(convId, inputText.value)
}
inputText.value = msg.content inputText.value = msg.content
nextTick(() => { nextTick(() => {
inputEl.value?.focus() inputEl.value?.focus()
@@ -1170,19 +1319,27 @@ function startEditFocus(msg: AiMessage): void {
}) })
} }
/** UX-09 取消编辑态:清空输入框 + 重置高度(父置 editingMsgId=null 后调) */ /** UX-09 取消编辑态:回填进入编辑前的草稿备份(无备份=照旧清空)+ 重置高度(父置 editingMsgId=null 后调)。
* 走查 P1:原实现无条件清空落盘,把用户未发送草稿一并抹掉;备份在编辑成功发送时已消费,
* 成功链到这里照旧清空落盘,行为不变。切会话触发的取消(activeConversationId 已变为新会话)
* 此处落盘会把旧会话草稿错写进新会话键下——跳过,由本组件 conv watcher 以旧会话 id 落盘。 */
function clearInput(): void { function clearInput(): void {
inputText.value = '' const backup = editBackupDraft
editBackupDraft = null
inputText.value = backup?.text ?? ''
pendingSkill.value = null pendingSkill.value = null
pendingImages.value = [] pendingImages.value = []
pendingMentionSpans.value = [] pendingMentionSpans.value = []
skillOpen.value = false skillOpen.value = false
mentionOpen.value = false mentionOpen.value = false
mentionStart.value = -1 mentionStart.value = -1
if (inputEl.value) inputEl.value.style.height = 'auto' autoResize()
// F2-2:清空即落盘(取消编辑不应残留旧稿到下次重启) // F2-2:落盘收口(取消编辑=回填备份稿;成功发送=空串删项,不残留旧稿到下次重启)。
// 会话切换场景:备份归属会话 ≠ 当前活跃会话,交给 conv watcher 落盘防错键
if (!backup || backup.convId === store.state.activeConversationId) {
persistDraftNow() persistDraftNow()
} }
}
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。 /** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢 * 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
@@ -1213,8 +1370,8 @@ function focus(): void {
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ── // ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
// 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像: // 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的 // 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入, // activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 回填草稿备份
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。 // (见 clearInput),本 watcher 后执行,旧会话保存到的即恢复后的草稿,草稿跨编辑态不丢。
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条, // F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为, // 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
@@ -1376,7 +1533,10 @@ defineExpose({
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */ /* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式); /* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
autosize 高度参数(min/height/max-height)为行为契约,禁动 */ autosize 高度参数(min/height/max-height)为行为契约,禁动。
走查:resize:vertical 已移除(显式 none 兜 UA 默认 both)——autoResize 随内容重算会把
手工拉伸高度塌回,两案(记住拉伸高度需 ResizeObserver 辨识拖拽 vs 去掉 resize)取改动
小者:单一 auto-grow 语义(20→200px),杜绝塌回 */
.ai-input { .ai-input {
width: 100%; width: 100%;
background: transparent; background: transparent;
@@ -1386,7 +1546,7 @@ defineExpose({
font-size: 13px; font-size: 13px;
font-family: var(--df-font-sans); font-family: var(--df-font-sans);
line-height: 1.5; line-height: 1.5;
resize: vertical; resize: none;
min-height: 20px; min-height: 20px;
height: 20px; height: 20px;
max-height: 200px; max-height: 200px;
@@ -1396,6 +1556,13 @@ defineExpose({
color: var(--df-text-dim); color: var(--df-text-dim);
} }
/* 走查:拖拽悬停落点高亮(outline 不占布局防 0.5px 抖动;accent-soft 弱底+accent 虚线描边) */
.ai-input--dragover {
outline: 1px dashed var(--df-accent);
outline-offset: -1px;
background: var(--df-accent-soft);
}
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */ /* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
.ai-input-row { .ai-input-row {
display: flex; display: flex;
-191
View File
@@ -1,191 +0,0 @@
<template>
<div class="conflict-resolver" v-if="conflicts.length > 0">
<div class="conflict-header">
<span class="conflict-title">⚠ {{ t('aiChat.conflictsPending', { n: conflicts.length }) }}</span>
</div>
<div v-for="c in conflicts" :key="c.id" class="conflict-item">
<div
class="conflict-file conflict-file--toggle"
role="button"
tabindex="0"
:aria-expanded="isExpanded(c.id)"
@click="toggleConflict(c.id)"
@keydown.enter.prevent="toggleConflict(c.id)"
@keydown.space.prevent="toggleConflict(c.id)"
>
<span class="conflict-arrow" :class="{ 'conflict-arrow--open': isExpanded(c.id) }">▸</span>
<span class="conflict-type-badge" :class="'badge--' + c.conflict_type">
{{ c.conflict_type === 'semantic' ? '语义' : '文件' }}
</span>
<code class="conflict-filepath">{{ c.file_path }}</code>
<span class="conflict-toggle-text">{{ isExpanded(c.id) ? t('aiChat.collapse') : t('aiChat.expand') }}</span>
</div>
<template v-if="isExpanded(c.id)">
<div class="conflict-diffs" v-if="c.diff_a || c.diff_b">
<div class="diff-panel diff-panel--a">
<div class="diff-panel-label">Agent A ({{ c.subtask_a || '?' }})</div>
<pre class="diff-content">{{ c.diff_a || '(无改动)' }}</pre>
</div>
<div class="diff-panel diff-panel--b">
<div class="diff-panel-label">Agent B ({{ c.subtask_b || '?' }})</div>
<pre class="diff-content">{{ c.diff_b || '(无改动)' }}</pre>
</div>
</div>
<div class="conflict-actions">
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'a' })">
{{ t('aiChat.acceptA') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'b' })">
{{ t('aiChat.acceptB') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'merged' })">
{{ t('aiChat.mergeBoth') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'manual' })">
{{ t('aiChat.manualResolve') }}
</button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ConflictInfo } from '@/api/types'
const { t } = useI18n()
const props = defineProps<{
conflicts: ConflictInfo[]
}>()
const emit = defineEmits<{
(e: 'resolve', payload: { id: string; resolution: string }): void
}>()
// 多冲突折叠态:以稳定 id 为 key(避免 index 在列表变化时错位)
const expandedConflicts = ref<Set<string>>(new Set())
// 阈值:冲突数 >= 3 时默认全折叠(M6 撑爆对话区只在多冲突场景发生);
// 单/双冲突信息量小,直接展开省一次点击
const COLLAPSE_THRESHOLD = 3
function isExpanded(id: string): boolean {
return expandedConflicts.value.has(id)
}
function toggleConflict(id: string): void {
const next = new Set(expandedConflicts.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
expandedConflicts.value = next
}
// 冲突列表变化(新增/减少)时重置默认态:< 阈值全展开,否则全折叠
const defaultExpanded = computed(() => props.conflicts.length < COLLAPSE_THRESHOLD)
watch(
() => props.conflicts.map((c) => c.id).join('|'),
() => {
const next = new Set<string>()
if (defaultExpanded.value) {
props.conflicts.forEach((c) => next.add(c.id))
}
expandedConflicts.value = next
},
{ immediate: true },
)
</script>
<style scoped>
.conflict-resolver {
border: 1px solid var(--df-warning);
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-card);
}
.conflict-header {
font-weight: 600;
margin-bottom: 8px;
color: var(--df-warning);
}
.conflict-item {
border-top: 1px solid var(--df-border);
padding: 8px 0;
}
.conflict-file {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.conflict-file--toggle {
cursor: pointer;
user-select: none;
}
.conflict-file--toggle:hover .conflict-filepath,
.conflict-file--toggle:hover .conflict-toggle-text {
color: var(--df-accent);
}
.conflict-arrow {
display: inline-block;
font-size: 10px;
color: var(--df-text-dim);
transition: transform 0.15s ease;
width: 10px;
}
.conflict-arrow--open {
transform: rotate(90deg);
}
.conflict-toggle-text {
margin-left: auto;
font-size: 11px;
color: var(--df-text-dim);
}
.conflict-type-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
}
.badge--file { background: var(--df-warning-bg); color: var(--df-warning); }
.badge--semantic { background: var(--df-danger-bg); color: var(--df-danger); }
.conflict-file code { font-size: 12px; color: var(--df-text); }
.conflict-diffs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.diff-panel {
border: 1px solid var(--df-border);
border-radius: 4px;
overflow: hidden;
}
.diff-panel--a { border-left: 3px solid var(--df-accent); }
.diff-panel--b { border-left: 3px solid var(--df-success); }
.diff-panel-label {
font-size: 11px;
padding: 2px 8px;
background: var(--df-bg);
color: var(--df-text-dim);
}
.diff-content {
font-size: 11px;
padding: 4px 8px;
margin: 0;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.conflict-actions {
display: flex;
gap: 4px;
}
</style>
+3 -2
View File
@@ -116,11 +116,12 @@ async function openSource(b: ContextBrief) {
} }
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并), /** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */ * 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
* 草稿模板句走 i18n(原硬编码中文,en 环境不再漏出中文) */
function bringIn(g: ProjectBriefs, b: ContextBrief) { function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId const convId = store.state.activeConversationId
if (!convId) return if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`) pushConvDraftAppend(convId, t('aiChat.contextBriefsDraft', { project: g.source_project, title: b.title, summary: b.summary }))
} }
</script> </script>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -139,6 +139,6 @@ function toggleExpanded(current: boolean) {
color: var(--df-text); color: var(--df-text);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
font-family: var(--df-font-mono, ui-monospace, monospace); font-family: var(--df-font-mono);
} }
</style> </style>
+38 -2
View File
@@ -9,8 +9,8 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
defineProps<{ defineProps<{
/** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl) */ /** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl;uploading=上传中占位 chip) */
pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string }[] pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string; uploading?: boolean }[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -21,7 +21,16 @@ const emit = defineEmits<{
<template> <template>
<div v-if="pendingImages.length" class="ai-img-preview-row"> <div v-if="pendingImages.length" class="ai-img-preview-row">
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview"> <div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
<!-- 走查:上传中占位 chip——url/dataUrl 未定时渲染 loading 态,不渲染空 src <img> -->
<div
v-if="img.uploading && !img.url && !img.dataUrl"
class="ai-img-preview-thumb ai-img-thumb--uploading"
>
<span class="ai-thumb-spinner" aria-hidden="true"></span>
<span class="ai-thumb-uploading-text">{{ t('aiChat.imageUploading') }}</span>
</div>
<img <img
v-else
:src="img.url || img.dataUrl" :src="img.url || img.dataUrl"
:alt="t('aiChat.imagePreviewAlt')" :alt="t('aiChat.imagePreviewAlt')"
class="ai-img-preview-thumb" class="ai-img-preview-thumb"
@@ -37,3 +46,30 @@ const emit = defineEmits<{
</div> </div>
</div> </div>
</template> </template>
<style scoped>
/* 走查:上传中占位态(与缩略图同尺寸盒,转圈+文案)。仅本组件消费的私有态样式,不入公共库 */
.ai-img-thumb--uploading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
background: var(--df-bg-card-hover);
}
.ai-thumb-spinner {
width: 12px;
height: 12px;
border: 1.5px solid var(--df-border);
border-top-color: var(--df-accent);
border-radius: 50%;
animation: ai-thumb-spin 0.8s linear infinite;
}
@keyframes ai-thumb-spin {
to { transform: rotate(360deg); }
}
.ai-thumb-uploading-text {
font-size: 9px;
color: var(--df-text-dim);
}
</style>
+12 -2
View File
@@ -13,9 +13,10 @@
//! - select(item):选中实体 → 替换 @query 段为 [类型: 名] 标记 //! - select(item):选中实体 → 替换 @query 段为 [类型: 名] 标记
//! - update:mentionIndex:鼠标 hover 时同步高亮下标 //! - update:mentionIndex:鼠标 hover 时同步高亮下标
import { ref, watch, nextTick } from 'vue'
import type { MentionItem } from './MentionItem' import type { MentionItem } from './MentionItem'
defineProps<{ const props = defineProps<{
/** 联想候选(扁平,项目 + 任务 + 灵感) */ /** 联想候选(扁平,项目 + 任务 + 灵感) */
mentionItems: MentionItem[] mentionItems: MentionItem[]
/** 按类型分组的候选(模板按组渲染组标题) */ /** 按类型分组的候选(模板按组渲染组标题) */
@@ -34,11 +35,18 @@ const emit = defineEmits<{
/** 鼠标 hover 时同步高亮下标 */ /** 鼠标 hover 时同步高亮下标 */
(e: 'update:mentionIndex', index: number): void (e: 'update:mentionIndex', index: number): void
}>() }>()
// 走查:键盘 ↑↓ 高亮项滚随——候选列表超高时选中项保持可见(block:nearest 无多余滚动)
const popRef = ref<HTMLElement | null>(null)
watch(() => props.mentionIndex, async (idx) => {
await nextTick()
popRef.value?.querySelector<HTMLElement>(`[data-mention-idx="${idx}"]`)?.scrollIntoView({ block: 'nearest' })
})
</script> </script>
<template> <template>
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) --> <!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
<div class="ai-skill-popover"> <div ref="popRef" class="ai-skill-popover">
<div v-if="!mentionItems.length" class="ai-skill-empty"> <div v-if="!mentionItems.length" class="ai-skill-empty">
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }} {{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
</div> </div>
@@ -50,6 +58,8 @@ const emit = defineEmits<{
:key="item.uid" :key="item.uid"
class="ai-skill-item" class="ai-skill-item"
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }" :class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
:data-mention-idx="mentionFlatIndex(item.uid)"
@mousedown.prevent
@click="emit('select', item)" @click="emit('select', item)"
@mouseenter="emit('update:mentionIndex', mentionFlatIndex(item.uid))" @mouseenter="emit('update:mentionIndex', mentionFlatIndex(item.uid))"
> >
+56 -1
View File
@@ -35,11 +35,19 @@ const props = defineProps<{
msg: AiMessage msg: AiMessage
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */ /** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
canEdit?: boolean canEdit?: boolean
/** M1 扩展:超长文本可折叠(1500 字阈值判定与折叠态由父级统一持有,本组件纯展示) */
longFoldable?: boolean
/** 当前是否折叠展示(longFoldable 为 true 时有意义) */
longFolded?: boolean
/** 折叠态按钮文案(父级 collapsedLongLabel 产出,含已折叠量;展开态复用 aiChat.collapse) */
foldLabel?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'copy', msg: AiMessage): void (e: 'copy', msg: AiMessage): void
(e: 'start-edit', msg: AiMessage): void (e: 'start-edit', msg: AiMessage): void
/** M1 扩展:折叠条点击,展开/收起状态由父级(toggleLong)处理 */
(e: 'toggle-long'): void
}>() }>()
// ── Input Augmentation: 按 mention 区间切段 ── // ── Input Augmentation: 按 mention 区间切段 ──
@@ -112,7 +120,7 @@ const imageParts = computed(() => msgImageParts(props.msg))
<div class="ai-msg-avatar ai-msg-avatar--user"> <div class="ai-msg-avatar ai-msg-avatar--user">
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div> </div>
<div class="ai-msg-bubble ai-msg-bubble--user"> <div class="ai-msg-bubble ai-msg-bubble--user" :class="{ 'ai-msg-bubble--collapsed': longFolded }">
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 --> <!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
<template v-if="skill"> <template v-if="skill">
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span> <span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
@@ -129,6 +137,12 @@ const imageParts = computed(() => msgImageParts(props.msg))
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" /> <img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
</div> </div>
</template> </template>
<!-- M1 扩展:超长文本折叠条(视觉/交互对齐 assistant 侧同款;点击经父级 toggleLong 切换) -->
<button
v-if="longFoldable"
class="ai-msg-bubble-toggle"
@click.stop="emit('toggle-long')"
>{{ longFolded ? (foldLabel || t('aiChat.collapse')) : t('aiChat.collapse') }}</button>
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 --> <!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)"> <button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
@@ -206,6 +220,47 @@ const imageParts = computed(() => msgImageParts(props.msg))
max-width: 200px; max-width: 200px;
max-height: 200px; max-height: 200px;
border-radius: 6px; border-radius: 6px;
/* lightbox 可放大暗示:点击开大图由父级 MessageList 容器级委托统一处理 */
cursor: zoom-in;
}
/* M1 扩展:超长 user 文本折叠(样式对齐 MessageList .ai-msg-bubble--collapsed 同款;
user 气泡有 --df-bg-card 底,渐变淡出随之;本组件 scoped,父级样式渗透不进内部节点) */
.ai-msg-bubble--user {
position: relative;
}
.ai-msg-bubble--collapsed {
max-height: 400px;
overflow: hidden;
}
.ai-msg-bubble--collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, var(--df-bg-card));
pointer-events: none; /* 让点击穿透到下方展开按钮 */
}
/* 展开按钮:气泡底部居中,置于渐变遮罩之上(同 MessageList .ai-msg-bubble-toggle) */
.ai-msg-bubble-toggle {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
z-index: 1;
padding: 2px 12px;
font-size: 11px;
line-height: 1.4;
color: var(--df-accent);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
cursor: pointer;
user-select: none;
}
.ai-msg-bubble-toggle:hover {
background: var(--df-bg-card-hover);
} }
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */ /* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
.ai-copy-btn, .ai-copy-btn,
+159 -18
View File
@@ -287,6 +287,20 @@ async function handleMessagesScroll(): Promise<void> {
container.scrollTop = Math.max(diff, 0) container.scrollTop = Math.max(diff, 0)
} }
// ── MESSAGE_CAP 淘汰边界条(走查批):循环 loadMoreHistory 直到 hasMore 尽头,补齐被裁早史 ──
// 每页走既有游标守卫/去重/过期丢弃;滚动停在原地不追底。全部补齐(hasMore=false)才清零
// messagesPruned 收边界条;中途失败(异常)保留计数与入口,可再点续拉。
async function reloadFullHistory(): Promise<void> {
const cur = loadMoreCursor
if (cur.loading) return
while (cur.hasMore && cur.earliestSeq != null) {
const before = store.state.messages.length
const inserted = await loadMoreHistory()
if (!inserted || store.state.messages.length === before) break // 无进展防死循环
}
if (!cur.hasMore) store.state.messagesPruned = 0
}
watch(() => store.state.messages.length, onContentChange) watch(() => store.state.messages.length, onContentChange)
// currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行) // currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
@@ -642,7 +656,10 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
} }
try { try {
const { default: html2canvas } = await import('html2canvas-pro') const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 }) // 底色合成:透明底 PNG 贴到浅色查看器会白字不可读;渲染前取 --df-bg 实值铺底,
// 深浅主题各自动随(token 缺失时回退 null 保持原透明行为)
const bg = getComputedStyle(document.documentElement).getPropertyValue('--df-bg').trim()
const canvas = await html2canvas(bubble, { backgroundColor: bg || null, useCORS: true, scale: 2 })
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png')) const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('toBlob failed') if (!blob) throw new Error('toBlob failed')
if (typeof ClipboardItem !== 'undefined') { if (typeof ClipboardItem !== 'undefined') {
@@ -660,7 +677,8 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
a.download = 'message.png' a.download = 'message.png'
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' }) // 降级下载文案如实区分:文件已落盘,不是「已复制到剪贴板」
emit('toast', { msg: t('aiChat.savedAsImageFile'), type: 'info' })
} catch { } catch {
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' }) emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
} }
@@ -757,6 +775,25 @@ async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
}, 1200) }, 1200)
} }
// ── 消息图片 lightbox(用户附件图 .ai-msg-image / AI 渲染图 .ai-md img):容器级事件委托
// 点击开大图;遮罩范式对齐 .ai-agent-peek-mask(fixed 居中),点遮罩/Esc 关闭 ──
const lightboxSrc = ref('')
function closeImageLightbox(): void {
lightboxSrc.value = ''
}
/** 委托命中判定:消息区任一 img 即开大图;链接内图片不拦(让 <a> 跳转优先) */
function onImageLightboxClick(e: MouseEvent): void {
const img = (e.target as HTMLElement | null)?.closest('img')
if (!img || img.closest('a')) return
const src = img.getAttribute('src')
if (src) lightboxSrc.value = src
}
/** 消息区容器级 click 分发:先查图片 lightbox,再走代码块复制(两者 target 不相交) */
function onMessagesClick(e: MouseEvent): void {
onImageLightboxClick(e)
void onMdCodeCopyClick(e)
}
// ── 错误气泡操作入口 ── // ── 错误气泡操作入口 ──
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段, // errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。 // 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
@@ -898,17 +935,18 @@ function useToggleSet(): { ids: Ref<Set<string>>; toggle: (id: string) => void }
/// 展开的折叠段 key 集合(点击分隔条切换);默认折叠 /// 展开的折叠段 key 集合(点击分隔条切换);默认折叠
const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet() const { ids: expandedSegmentIds, toggle: toggleSegment } = useToggleSet()
// M1:assistant 纯文本气泡超长折叠,避免单条 AI 回复撑爆对话区。 // M1:纯文本气泡超长折叠(assistant + user 都生效),避免单条回复撑爆对话区。
// 仅对「已完成(非流式)」assistant 文本生效;流式中的消息不折叠(delta 重排抖动)。 // 仅对「已完成(非流式)」文本生效;流式中的消息不折叠(delta 重排抖动)。
// 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。 // 阈值 1500 字符:约 markdown 渲染后一屏(400px@13px/1.6≈35行)的源文本量,超此即判长。
// 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。 // 折叠 max-height 同步 400px(见 CSS .ai-msg-bubble--collapsed,两处一致)。
const LONG_MSG_THRESHOLD = 1500 const LONG_MSG_THRESHOLD = 1500
/// 展开的长消息 id 集合(点击底部按钮切换);默认折叠 /// 展开的长消息 id 集合(点击底部按钮切换);默认折叠
const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet() const { ids: expandedLongIds, toggle: toggleLong } = useToggleSet()
function isLongMsg(msg: AiMessage): boolean { function isLongMsg(msg: AiMessage): boolean {
return msg.role === 'assistant' && !!msg.content && msg.content.length > LONG_MSG_THRESHOLD return !!msg.content && msg.content.length > LONG_MSG_THRESHOLD
} }
/// 是否折叠展示该 assistant 文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。 /// 是否折叠展示该长文本气泡:长消息 + 未手动展开 + 非流式(当前未在流式生成)。
/// 流式约束对 user 恒不触发(isLastAi 仅认 assistant),user 消息不参与流式生成。
function shouldCollapseLong(msg: AiMessage): boolean { function shouldCollapseLong(msg: AiMessage): boolean {
if (!isLongMsg(msg)) return false if (!isLongMsg(msg)) return false
if (expandedLongIds.value.has(msg.id)) return false if (expandedLongIds.value.has(msg.id)) return false
@@ -1087,12 +1125,25 @@ function needsTimeSep(ts: number, prevTs: number): boolean {
const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>() const _bucketCache = new Map<string, { items: RenderItem[]; endTs: number }>()
const BUCKET_CACHE_LIMIT = 128 const BUCKET_CACHE_LIMIT = 128
/// 轻量字符串哈希(对齐 useStreamRenderer.ts simpleHash 范式,djb2,非加密):
/// bucketSig 每条消息重算,长文本全文拼 key 既慢又驻留大串,压成 length+hash
function simpleHash(s: string): number {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
return h >>> 0
}
/** 签名片段压缩:content/reasoning 用 length+hash 替代全文(内容变→哈希变,签名语义不变) */
function lenHash(v: string | null | undefined): string {
const s = v ?? ''
return s.length + ':' + simpleHash(s)
}
function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string { function bucketSig(msgs: AiMessage[], round: number, executing: boolean, prevTs: number, traceExpanded: boolean): string {
const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)] const parts: string[] = [round + '#' + (executing ? 1 : 0) + '#' + prevTs + '#' + (traceExpanded ? 1 : 0)]
for (const m of msgs) { for (const m of msgs) {
let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0) let s = m.id + ':' + m.role + ':' + (m.status ?? '') + ':' + (m.isError ? 1 : 0) + ':' + (m.isRetry ? 1 : 0)
+ ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '') + ':' + (m.timestamp ?? 0) + ':' + (m.model ?? '')
+ ':' + (m.content ?? '') + ':' + (m.reasoning_content ?? '') + ':' + lenHash(m.content) + ':' + lenHash(m.reasoning_content)
+ ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '') + ':' + (m.tokenUsage?.prompt ?? m.prompt_tokens ?? '')
+ ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '') + ':' + (m.tokenUsage?.completion ?? m.completion_tokens ?? '')
+ ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '') + ':' + (m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? '')
@@ -1585,11 +1636,13 @@ function closeTokenPopoverOnOutsideClick(): void {
} }
document.addEventListener('click', closeTokenPopoverOnOutsideClick) document.addEventListener('click', closeTokenPopoverOnOutsideClick)
// 浮层 Esc 关闭:子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅外点关闭, // 浮层 Esc 关闭:消息图片 lightbox + 子代理轨迹浮层 + token 详情 popover(F6-3:后者原先仅
// 键盘用户开了面板只能点别处)。两者同时开着时逐层关(先浮层后 popover),Esc 不冒泡旁路。 // 外点关闭,键盘用户开了面板只能点别处)。同时开着时逐层关(先 lightbox 后浮层后 popover),
// Esc 不冒泡旁路。
function closeOverlaysOnEsc(e: KeyboardEvent): void { function closeOverlaysOnEsc(e: KeyboardEvent): void {
if (e.key !== 'Escape') return if (e.key !== 'Escape') return
if (agentPeekVisible.value) closeAgentTrace() if (lightboxSrc.value) closeImageLightbox()
else if (agentPeekVisible.value) closeAgentTrace()
else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null else if (tokenPopoverMsgId.value !== null) tokenPopoverMsgId.value = null
} }
document.addEventListener('keydown', closeOverlaysOnEsc) document.addEventListener('keydown', closeOverlaysOnEsc)
@@ -1607,10 +1660,10 @@ defineExpose({
<template> <template>
<!-- Messages --> <!-- Messages -->
<!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 --> <!-- UX第五轮 P2-G1:role="log"+aria-live=polite,读屏用户可感知流式新消息与回复更新 -->
<div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick"> <div class="ai-messages" ref="messagesContainer" role="log" aria-live="polite" @scroll="handleMessagesScroll" @click="onMessagesClick">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue --> <!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
<EmptyState <EmptyState
v-if="store.state.messages.length === 0 && !store.state.streaming" v-if="store.state.messages.length === 0 && !store.state.streaming && store.state.providersLoaded"
:has-provider="store.state.providers.length > 0" :has-provider="store.state.providers.length > 0"
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)" @send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
@go-to-settings="emit('go-to-settings')" @go-to-settings="emit('go-to-settings')"
@@ -1634,6 +1687,14 @@ defineExpose({
每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染; 每项外层包 .ai-msg-slot(虚拟滚动已移除,现恒渲染;
slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。 slot 仅作 flex 子占位 + 消息分组的语义容器,gap 由 .ai-messages 承担)。
UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 --> UX 密度改造:timesep 项=相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线。 -->
<!-- MESSAGE_CAP 淘汰边界条(走查批):滚动淘汰不再静默——头部提示被裁条数,点击循环
loadMoreHistory 补齐完整会话(复用 G3.5 分页链路:游标守卫+去重+过期丢弃)。 -->
<button
v-if="store.state.messagesPruned > 0"
class="ai-pruned-banner"
:disabled="loadMoreCursor.loading"
@click="reloadFullHistory"
>{{ $t('aiChat.prunedBanner', { n: store.state.messagesPruned }) }}</button>
<template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']"> <template v-for="item in renderItems" :key="item.key" v-memo="[item, store.state.streaming, isViewingGenerating, expandedThinkIds, expandedJsonIds, expandedLongIds, tokenPopoverMsgId, editingMsgId, agentPeekLoadingId, _mdRenderKey, taskTitleCache, item.kind === 'msg' && isLastAi(item.msg) && store.state.streaming ? decoratedStreamingBlocks : '']">
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看 <!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) --> 每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
@@ -1775,8 +1836,11 @@ defineExpose({
class="ai-agent-fin" class="ai-agent-fin"
:class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]" :class="['ai-agent-fin--' + agentFinTone(item.card.status), { 'ai-agent-fin--notrace': agentFinNoTrace(item.card) }]"
role="button" role="button"
tabindex="0"
:title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')" :title="agentFinNoTrace(item.card) ? $t('ai.agentFinished.noTrace') : $t('ai.agentFinished.viewHint')"
@click="openAgentTrace(item.card)" @click="openAgentTrace(item.card)"
@keydown.enter.prevent="openAgentTrace(item.card)"
@keydown.space.prevent="openAgentTrace(item.card)"
> >
<span class="ai-agent-fin-dot"></span> <span class="ai-agent-fin-dot"></span>
<!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 --> <!-- A-3 并行多子代理卡序号身份:agent-{n} 解析成功才渲染,防多卡轮次乱序误读 -->
@@ -1856,13 +1920,18 @@ defineExpose({
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span> <span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div> <div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
</div> </div>
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) --> <!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染)
M1 扩展:超长 user 文本折叠——阈值判定/折叠态/文案由父级统一持有,子组件纯展示 -->
<MessageItem <MessageItem
v-else-if="item.msg.role === 'user'" v-else-if="item.msg.role === 'user'"
:msg="item.msg" :msg="item.msg"
:can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId" :can-edit="isLastUser(item.msg) && !store.state.streaming && item.msg.id !== editingMsgId"
:long-foldable="isLongMsg(item.msg)"
:long-folded="shouldCollapseLong(item.msg)"
:fold-label="collapsedLongLabel(item.msg)"
@copy="copyMsgContent" @copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)" @start-edit="(m) => emit('start-edit', m)"
@toggle-long="toggleLong(item.msg.id)"
/> />
<!-- AI 消息 --> <!-- AI 消息 -->
@@ -2119,6 +2188,20 @@ defineExpose({
</div> </div>
</div> </div>
</div> </div>
<!-- 消息图片 lightbox:用户附件图/AI 渲染图点击看大图;点遮罩或 Esc 关闭(范式同 peek 浮层,
模板根级兄弟,position:fixed 不受 .ai-messages 滚动容器 overflow 裁剪) -->
<div v-if="lightboxSrc" class="ai-img-lightbox-mask" @click.self="closeImageLightbox">
<img class="ai-img-lightbox-img" :src="lightboxSrc" :alt="$t('aiChat.lightboxAlt')" />
<button
class="ai-img-lightbox-close"
:title="$t('aiChat.lightboxClose')"
:aria-label="$t('aiChat.lightboxClose')"
@click="closeImageLightbox"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -2309,7 +2392,8 @@ defineExpose({
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动, 不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 点击整块切换 --expanded(移除高度限制)展开/收起完整内容。
F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上 F1-2:灰字由硬编码 #b0b0b0 改 --df-text-dim——硬编码值在浅色主题白底上
对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。 ── */ 对比度约 2.6:1 不可读;token 双主题各有值,弱化语义不变。
UX 走查:恢复文本可选中(user-select:none 移除)——JSON 工具结果也是要能复制的内容。 ── */
.ai-msg-json { .ai-msg-json {
max-height: 80px; max-height: 80px;
overflow-y: auto; overflow-y: auto;
@@ -2322,7 +2406,6 @@ defineExpose({
line-height: 1.5; line-height: 1.5;
color: var(--df-text-dim); color: var(--df-text-dim);
cursor: pointer; cursor: pointer;
user-select: none;
} }
.ai-msg-json pre { .ai-msg-json pre {
margin: 0; margin: 0;
@@ -2371,6 +2454,21 @@ defineExpose({
padding: 8px 12px; padding: 8px 12px;
} }
/* ── MESSAGE_CAP 淘汰边界条(走查批):列表头部轻提示,一键补齐被裁早史 ── */
.ai-pruned-banner {
align-self: center;
margin: 4px 0;
padding: 4px 14px;
border: 1px dashed var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg-card);
color: var(--df-text-dim);
font-size: var(--df-fs-body);
cursor: pointer;
}
.ai-pruned-banner:hover { color: var(--df-accent); border-color: var(--df-accent); }
.ai-pruned-banner:disabled { opacity: 0.5; cursor: default; }
/* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */ /* ── 轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转) ── */
.ai-trace-nav { .ai-trace-nav {
position: sticky; position: sticky;
@@ -2423,7 +2521,9 @@ defineExpose({
transition: opacity 0.2s; transition: opacity 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
.ai-msg-ai:hover .ai-copy-btn--ai { .ai-msg-ai:hover .ai-copy-btn--ai,
/* UX第五轮 P2-M1 同款键盘可达:Tab 聚到气泡内按钮/按钮自身聚焦时显形 */
.ai-msg-ai:focus-within .ai-copy-btn--ai {
opacity: 1; opacity: 1;
} }
.ai-copy-btn:hover { .ai-copy-btn:hover {
@@ -2472,7 +2572,9 @@ defineExpose({
opacity: 0; opacity: 0;
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease); transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
} }
.ai-md :deep(.md-code-block:hover .md-code-copy) { .ai-md :deep(.md-code-block:hover .md-code-copy),
/* 键盘可达(对齐 .ai-copy-btn focus 范式):Tab 聚到复制钮自身时显形 */
.ai-md :deep(.md-code-copy:focus-visible) {
opacity: 1; opacity: 1;
} }
.ai-md :deep(.md-code-copy:hover) { .ai-md :deep(.md-code-copy:hover) {
@@ -3017,7 +3119,7 @@ defineExpose({
/* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */ /* A-3 并行多子代理卡序号徽标:等宽小号弱化展示,解析失败不渲染(v-if 已守) */
.ai-agent-fin-seq { .ai-agent-fin-seq {
flex-shrink: 0; flex-shrink: 0;
font-family: var(--df-font-mono, monospace); font-family: var(--df-font-mono);
font-size: 11px; font-size: 11px;
opacity: 0.65; opacity: 0.65;
} }
@@ -3133,6 +3235,45 @@ defineExpose({
text-align: center; text-align: center;
padding: 24px 0; padding: 24px 0;
} }
/* ═══ 消息图片 lightbox(用户附件图/AI 渲染图点击看大图;遮罩范式对齐 .ai-agent-peek-mask) ═══ */
.ai-img-lightbox-mask {
position: fixed;
inset: 0;
z-index: 310;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.ai-img-lightbox-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--df-radius-md);
box-shadow: var(--df-shadow-md);
}
.ai-img-lightbox-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
padding: 6px;
border: none;
border-radius: var(--df-radius-sm);
background: rgba(0, 0, 0, 0.4);
color: #fff;
cursor: pointer;
}
.ai-img-lightbox-close:hover {
background: rgba(0, 0, 0, 0.6);
}
/* 可放大暗示:用户附件图光标在 MessageItem.vue scoped 内;AI markdown 图(v-html 注入,
非 scoped 受控)经 :deep 补同款 zoom-in */
.ai-md :deep(img) {
cursor: zoom-in;
}
.ai-agent-peek-msg { .ai-agent-peek-msg {
border: 0.5px solid var(--df-border); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); border-radius: var(--df-radius);
+68 -5
View File
@@ -4,7 +4,17 @@
数据走 useAiOutbox(后端命令未接线时静默降级为空列表,抽屉可用不崩)。 --> 数据走 useAiOutbox(后端命令未接线时静默降级为空列表,抽屉可用不崩)。 -->
<Teleport to="body"> <Teleport to="body">
<div v-if="open" class="outbox-overlay" @click.self="emit('close')"> <div v-if="open" class="outbox-overlay" @click.self="emit('close')">
<div class="outbox-panel" role="dialog" aria-modal="true" :aria-label="t('aiChat.outboxTitle')"> <!-- 走查:开抽屉聚焦面板 + Tab 焦点圈闭(tabindex=-1 使面板可编程聚焦;
role=dialog + aria-modal 已有,补键盘可达闭环) -->
<div
ref="panelRef"
class="outbox-panel"
role="dialog"
aria-modal="true"
:aria-label="t('aiChat.outboxTitle')"
tabindex="-1"
@keydown="onPanelKeydown"
>
<div class="outbox-header"> <div class="outbox-header">
<h3 class="outbox-title">{{ t('aiChat.outboxTitle') }}</h3> <h3 class="outbox-title">{{ t('aiChat.outboxTitle') }}</h3>
<button type="button" class="outbox-close" :aria-label="t('common.close')" @click="emit('close')">✕</button> <button type="button" class="outbox-close" :aria-label="t('common.close')" @click="emit('close')">✕</button>
@@ -19,6 +29,8 @@
<span class="outbox-badge" :class="'outbox-badge--' + item.status">{{ statusLabel(item.status) }}</span> <span class="outbox-badge" :class="'outbox-badge--' + item.status">{{ statusLabel(item.status) }}</span>
<span class="outbox-text" :title="item.text">{{ summarize(item.text) }}</span> <span class="outbox-text" :title="item.text">{{ summarize(item.text) }}</span>
</div> </div>
<!-- 走查:failed 行展示后端 last_error 失败原因,不再只有一个「失败」徽章无从排查 -->
<div v-if="item.status === 'failed' && item.last_error" class="outbox-row-error">{{ item.last_error }}</div>
<!-- 行操作仅活跃项(pending/sending/failed)展示;终态行只读留档 --> <!-- 行操作仅活跃项(pending/sending/failed)展示;终态行只读留档 -->
<div v-if="isActive(item)" class="outbox-row-actions"> <div v-if="isActive(item)" class="outbox-row-actions">
<template v-if="editingId === item.id"> <template v-if="editingId === item.id">
@@ -66,6 +78,9 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 走查:取消定时消息二次确认弹层(自持 useConfirm + ConfirmDialog,同 ChatInput/QueuePanel
范式——useConfirm 非单例,发起组件须自渲染一份) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</Teleport> </Teleport>
</template> </template>
@@ -73,10 +88,12 @@
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。 // OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局); // 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。 // 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox' import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useConfirm } from '../../composables/useConfirm'
const props = defineProps<{ const props = defineProps<{
/** 抽屉开合(ChatInput ⏰ 按钮控制;开时拉取列表,关时仅隐藏不销毁缓存) */ /** 抽屉开合(ChatInput ⏰ 按钮控制;开时拉取列表,关时仅隐藏不销毁缓存) */
@@ -91,18 +108,51 @@ const { t } = useI18n()
const store = useAiStore() const store = useAiStore()
const { items, loading, refresh, enqueue, cancel, reschedule, sendNow } = useAiOutbox() const { items, loading, refresh, enqueue, cancel, reschedule, sendNow } = useAiOutbox()
// ── 走查:确认弹层(取消定时消息不可逆,对齐 ChatInput/QueuePanel 自持范式) ──
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 走查:开抽屉聚焦面板 + Tab 焦点圈闭(键盘可达闭环;Esc 归下方 document 级仲裁)
const panelRef = ref<HTMLElement | null>(null)
// 进入抽屉拉取(操作后由 composable 自动失效重拉,此处只管开合时机) // 进入抽屉拉取(操作后由 composable 自动失效重拉,此处只管开合时机)
watch(() => props.open, (v) => { watch(() => props.open, (v) => {
if (v) void refresh() if (v) {
void refresh()
nextTick(() => panelRef.value?.focus())
}
}) })
// 走查:简易 Tab trap——焦点在面板内 Tab 到尾元素回绕到首,Shift+Tab 反向;
// 焦点意外漏出面板时一并发回面板,防 Tab 落进遮罩后的页面
function onPanelKeydown(e: KeyboardEvent): void {
if (e.key !== 'Tab' || !panelRef.value) return
const focusables = panelRef.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
)
if (focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement
// 面板容器自身(open 时的初始焦点)视为「未入列」:Tab 前进到首元素、Shift+Tab 回绕到尾元素
const inside = active !== panelRef.value && active instanceof Node && panelRef.value.contains(active)
if (e.shiftKey && (active === first || !inside)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault()
first.focus()
}
}
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。 // F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡, // 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果; // 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫)。 // IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫);
// 确认弹层可见时 Esc 归弹层(ConfirmDialog 自带 document 级消费),不关抽屉。
function onEscClose(e: KeyboardEvent): void { function onEscClose(e: KeyboardEvent): void {
if (!props.open || e.key !== 'Escape') return if (!props.open || e.key !== 'Escape') return
if (e.isComposing || e.keyCode === 229) return if (e.isComposing || e.keyCode === 229) return
if (confirmState.value.visible) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
emit('close') emit('close')
@@ -175,7 +225,11 @@ function startReschedule(item: AiOutboxItem): void {
async function confirmReschedule(item: AiOutboxItem): Promise<void> { async function confirmReschedule(item: AiOutboxItem): Promise<void> {
const due = editDue.value ? new Date(editDue.value).getTime() : NaN const due = editDue.value ? new Date(editDue.value).getTime() : NaN
if (Number.isNaN(due)) return // 走查:非法值(NaN)原静默 return,用户不知道点保存为何无反应;提示后留在编辑态可改
if (Number.isNaN(due)) {
notice.value = t('aiChat.outboxDueInvalid')
return
}
acting.value = true acting.value = true
const ok = await reschedule(item.id, due) const ok = await reschedule(item.id, due)
acting.value = false acting.value = false
@@ -194,6 +248,8 @@ async function onSendNow(item: AiOutboxItem): Promise<void> {
} }
async function onCancel(item: AiOutboxItem): Promise<void> { async function onCancel(item: AiOutboxItem): Promise<void> {
// 走查:取消把条目直接置终态不可恢复,补二次确认(对齐清空队列/删对话确认范式)
if (!(await confirmDialog(t('aiChat.outboxCancelConfirm')))) return
acting.value = true acting.value = true
const ok = await cancel(item.id) const ok = await cancel(item.id)
acting.value = false acting.value = false
@@ -334,6 +390,13 @@ async function onCreate(): Promise<void> {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* 走查:failed 行失败原因(last_error 原始串,danger 色弱化呈现;anywhere 断行兜长串) */
.outbox-row-error {
font-size: 11px;
color: var(--df-danger);
opacity: 0.9;
overflow-wrap: anywhere;
}
.outbox-row-actions { .outbox-row-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+11 -2
View File
@@ -3,12 +3,13 @@
//! //!
//! 职责:显示 /@ 联想浮层(Filtered skill list)、选中/清除技能、关联 i18n //! 职责:显示 /@ 联想浮层(Filtered skill list)、选中/清除技能、关联 i18n
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { SkillInfo } from '../../api/types' import type { SkillInfo } from '../../api/types'
const { t } = useI18n() const { t } = useI18n()
defineProps<{ const props = defineProps<{
/** 联想浮层是否打开 */ /** 联想浮层是否打开 */
skillOpen: boolean skillOpen: boolean
/** 过滤后的技能列表 */ /** 过滤后的技能列表 */
@@ -26,6 +27,13 @@ const emit = defineEmits<{
(e: 'clear'): void (e: 'clear'): void
(e: 'update:skillIndex', index: number): void (e: 'update:skillIndex', index: number): void
}>() }>()
// 走查:键盘 ↑↓ 高亮项滚随——技能列表超高时选中项保持可见(block:nearest 无多余滚动)
const popRef = ref<HTMLElement | null>(null)
watch(() => props.skillIndex, async (idx) => {
await nextTick()
popRef.value?.querySelector<HTMLElement>(`[data-skill-idx="${idx}"]`)?.scrollIntoView({ block: 'nearest' })
})
</script> </script>
<template> <template>
@@ -40,7 +48,7 @@ const emit = defineEmits<{
</div> </div>
<!-- 技能联想浮层 --> <!-- 技能联想浮层 -->
<div v-if="skillOpen" class="ai-skill-popover"> <div v-if="skillOpen" ref="popRef" class="ai-skill-popover">
<div v-if="!filteredSkills.length" class="ai-skill-empty"> <div v-if="!filteredSkills.length" class="ai-skill-empty">
{{ skillQuery.length ? t('aiChat.skillNoMatch') : t('aiChat.skillNotLoaded') }} {{ skillQuery.length ? t('aiChat.skillNoMatch') : t('aiChat.skillNotLoaded') }}
</div> </div>
@@ -49,6 +57,7 @@ const emit = defineEmits<{
:key="s.path" :key="s.path"
class="ai-skill-item" class="ai-skill-item"
:class="{ 'ai-skill-item--active': i === skillIndex }" :class="{ 'ai-skill-item--active': i === skillIndex }"
:data-skill-idx="i"
@click="emit('select', s)" @click="emit('select', s)"
@mouseenter="emit('update:skillIndex', i)" @mouseenter="emit('update:skillIndex', i)"
> >
+136 -1422
View File
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@
'ai-conv-item--archived': conv.archived, 'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned, 'ai-conv-item--pinned': conv.pinned,
}" }"
role="option"
:aria-selected="conv.id === store.state.activeConversationId"
tabindex="0" tabindex="0"
@click="onSelectSearchResult(conv.id)" @click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)" @keydown="onItemKeydown($event, conv.id, true)"
@@ -26,11 +28,11 @@
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger' : pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused' : conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'" : 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined" :title="convStatusTitle(conv)"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致; <!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) --> 双击入口对齐分组视图,菜单入口供键盘用户(P2-6)。走查批E:归档项排除改名入口(两态对齐) -->
<input <input
v-if="editingConvId === conv.id" v-if="editingConvId === conv.id"
:ref="setEditEl" :ref="setEditEl"
@@ -43,8 +45,8 @@
<span <span
v-else v-else
class="ai-conv-item-title" class="ai-conv-item-title"
:title="conv.title || t('aiChat.doubleClickToRename')" :title="conv.title || (conv.archived ? '' : t('aiChat.doubleClickToRename'))"
@dblclick.stop="startRename(conv)" @dblclick.stop="!conv.archived && startRename(conv)"
> >
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) --> <!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
@@ -54,14 +56,14 @@
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time" :title="formatAbsolute(conv.updated_at)">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id, $event)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu" :class="{ 'ai-conv-actions-menu--up': menuUp }">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }} {{ t('aiChat.renameConversation') }}
@@ -116,6 +118,8 @@
:ref="(el) => setItemEl(conv.id, el)" :ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item" class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
role="option"
:aria-selected="conv.id === store.state.activeConversationId"
tabindex="0" tabindex="0"
@click="beginSwitch(conv.id)" @click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id, true)" @keydown="onItemKeydown($event, conv.id, true)"
@@ -128,7 +132,7 @@
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger' : pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused' : conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'" : 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined" :title="convStatusTitle(conv)"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<input <input
@@ -161,14 +165,14 @@
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time" :title="formatAbsolute(conv.updated_at)">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id, $event)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu" :class="{ 'ai-conv-actions-menu--up': menuUp }">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }} {{ t('aiChat.renameConversation') }}
@@ -201,7 +205,8 @@
v-for="conv in recoverable" v-for="conv in recoverable"
:key="'r-' + conv.id" :key="'r-' + conv.id"
class="ai-conv-item ai-conv-item--recoverable" class="ai-conv-item ai-conv-item--recoverable"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--recover-busy': conv.id === recoveringId }"
:aria-busy="conv.id === recoveringId ? 'true' : undefined"
tabindex="0" tabindex="0"
:title="t('aiChat.recoverableHint')" :title="t('aiChat.recoverableHint')"
@click="onRecover(conv.id)" @click="onRecover(conv.id)"
@@ -213,9 +218,13 @@
<span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span> <span class="df-dot ai-conv-status ai-conv-status--paused" aria-hidden="true"></span>
<span class="ai-conv-item-title"> <span class="ai-conv-item-title">
{{ conv.title || t('aiChat.newConversation') }} {{ conv.title || t('aiChat.newConversation') }}
<span class="ai-conv-recover-badge">⏸</span> <!-- 走查批E:恢复进行中行级 busy——spinner 替换 ⏸(视觉反馈),点击防重由侧栏 onRecover 守卫 -->
<span v-if="recoveringId === conv.id" class="ai-recover-spin" aria-hidden="true">
<svg class="ai-btn-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span v-else class="ai-conv-recover-badge" :title="t('aiChat.recoverableHint')">⏸</span>
</span>
<span class="ai-conv-item-time" :title="formatAbsolute(conv.updated_at)">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
</div> </div>
</template> </template>
@@ -241,6 +250,8 @@
:ref="(el) => setItemEl(conv.id, el)" :ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived" class="ai-conv-item ai-conv-item--archived"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }" :class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
role="option"
:aria-selected="conv.id === store.state.activeConversationId"
tabindex="0" tabindex="0"
@click="beginSwitch(conv.id)" @click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)" @keydown="onItemKeydown($event, conv.id)"
@@ -253,7 +264,7 @@
: pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger' : pendingCountOf(conv.id) > 0 ? 'ai-conv-status--danger'
: conv.loop_state === 'parked' ? 'ai-conv-status--paused' : conv.loop_state === 'parked' ? 'ai-conv-status--paused'
: 'df-dot--stop'" : 'df-dot--stop'"
:title="store.isGenerating(conv.id) ? t('aiChat.generating') : undefined" :title="convStatusTitle(conv)"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<span class="ai-conv-item-title" :title="conv.title || ''"> <span class="ai-conv-item-title" :title="conv.title || ''">
@@ -265,14 +276,14 @@
<svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg> <svg aria-hidden="true" width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/></svg>
</span> </span>
</span> </span>
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span> <span class="ai-conv-item-time" :title="formatAbsolute(conv.updated_at)">{{ formatRelative(conv.updated_at) }}</span>
</div> </div>
<div class="ai-conv-item-actions"> <div class="ai-conv-item-actions">
<div class="ai-conv-item-more"> <div class="ai-conv-item-more">
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')"> <button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id, $event)" :title="t('aiChat.moreActions')" :aria-label="t('aiChat.moreActions')">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg> <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button> </button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu"> <div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu" :class="{ 'ai-conv-actions-menu--up': menuUp }">
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()"> <button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg> <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }} {{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
@@ -291,16 +302,18 @@
</div> </div>
</template> </template>
<!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开) --> <!-- F4-1: 会话列表分页——底部「加载更多」续拉下一页(首屏 50/组,归档组同口径放开)。
走查批E:失败进一次性「加载失败,点击重试」态(danger 染色),下次点击在 composable 入口复位回常态 -->
<div <div
v-if="convListPaging.hasMore" v-if="convListPaging.hasMore"
class="ai-conv-load-more" class="ai-conv-load-more"
:class="{ 'ai-conv-load-more--fail': convListPaging.loadFailed }"
role="button" role="button"
tabindex="0" tabindex="0"
@click="onLoadMore" @click="onLoadMore"
@keydown.enter.prevent="onLoadMore" @keydown.enter.prevent="onLoadMore"
@keydown.space.prevent="onLoadMore" @keydown.space.prevent="onLoadMore"
>{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}</div> >{{ convListPaging.loading ? t('common.loading') : convListPaging.loadFailed ? t('aiChat.loadMoreFailRetry') : t('gitChanges.loadMore') }}</div>
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty"> <div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
{{ t('aiChat.emptyConversation') }} {{ t('aiChat.emptyConversation') }}
@@ -328,17 +341,19 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai' import { useAiStore } from '../../../stores/ai'
import { getConvBoundary } from '../../../composables/ai/useTaskBoundary' import { getConvBoundary } from '../../../composables/ai/useTaskBoundary'
import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations' import { convListPaging, loadMoreConversations } from '../../../composables/ai/useAiConversations'
import { formatRelative } from '../../../utils/time' import { formatRelative, formatAbsolute } from '../../../utils/time'
import type { AiConversationSummary } from '../../../api/types' import type { AiConversationSummary } from '../../../api/types'
/** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */ /** G5 可恢复会话条目(主文件数据源透传,字段口径同 aiApi.listRecoverable) */
interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string } interface RecoverableConv { id: string; title: string | null; model: string | null; updated_at: string }
defineProps<{ const props = defineProps<{
/** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */ /** V1 跨会话待审批计数(主文件 computed:三响应式源锚定 + 轮询兜底;函数透传,模板调用可追踪) */
pendingCountOf: (convId: string) => number pendingCountOf: (convId: string) => number
/** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */ /** G5 可恢复分组数据(主文件拉取与防重,本组件纯渲染 + emit 恢复动作) */
recoverable: RecoverableConv[] recoverable: RecoverableConv[]
/** G5 走查批E:恢复进行中的会话 id(主文件持防重源透传,行级 busy 态:spinner/禁点) */
recoveringId: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -411,28 +426,67 @@ function onRenameKeydown(e: KeyboardEvent, convId: string) {
} }
} }
/** 走查批E listbox 键盘范式:ArrowUp/Down 在列表内相邻会话项间移焦(10-03 P2-6 键盘可达的
* 延伸)。v-show 折叠项(display:none,offsetParent 为 null)不参与;已在边界时不动,
* 不 preventDefault 交还容器原生滚动。 */
function focusAdjacentItem(e: KeyboardEvent, dir: 1 | -1): void {
const current = e.currentTarget as HTMLElement
const list = current.closest('[role="listbox"]')
if (!list) return
const items = Array.from(list.querySelectorAll<HTMLElement>('.ai-conv-item'))
.filter(el => el.offsetParent !== null)
const idx = items.indexOf(current)
if (idx === -1) return
const next = items[idx + dir]
if (next) {
e.preventDefault()
next.focus()
}
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div // UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。 // 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发。
// renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名, // renamable=true 的会话项(搜索结果/时间分组,即双击标题可重命名的项)额外支持 F2 键盘重命名,
// 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例;归档项不支持重命名故不传该标志)。 // 作为 .ai-conv-item-title 双击入口的键盘等价(文件管理器惯例)。走查批E:归档项两态对齐
// 不可改名——搜索态归档项虽传 renamable,F2 与 dblclick 同判 archived 排除。
function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) { function onItemKeydown(e: KeyboardEvent, convId: string, renamable = false) {
if (e.isComposing || e.keyCode === 229) return if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return if (e.target !== e.currentTarget) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
focusAdjacentItem(e, e.key === 'ArrowDown' ? 1 : -1)
return
}
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
beginSwitch(convId) beginSwitch(convId)
} else if (renamable && e.key === 'F2') { } else if (renamable && e.key === 'F2') {
e.preventDefault() e.preventDefault()
const conv = store.state.conversations.find(c => c.id === convId) const conv = store.state.conversations.find(c => c.id === convId)
if (conv) startRename(conv) if (conv && !conv.archived) startRename(conv)
} }
} }
// ── 会话项「更多」操作菜单(置顶/归档/删除)── // ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null) const actionsOpenId = ref<string | null>(null)
// 走查批E:菜单底部裁剪——触发项位于列表可视区后半时菜单向上翻(默认向下展开会被
// .ai-conv-list 的 overflow 裁掉)。每次展开时按触发项位置判定,收起态保持上次结果无副作用。
const menuUp = ref(false)
function toggleActionsMenu(id: string) { function toggleActionsMenu(id: string, e: MouseEvent) {
actionsOpenId.value = actionsOpenId.value === id ? null : id if (actionsOpenId.value === id) {
actionsOpenId.value = null
return
}
actionsOpenId.value = id
const item = (e.currentTarget as HTMLElement | null)?.closest('.ai-conv-item')
const list = item?.closest('.ai-conv-list')
if (item && list) {
const itemTop = item.getBoundingClientRect().top
const listRect = list.getBoundingClientRect()
menuUp.value = itemTop > listRect.top + listRect.height / 2
} else {
menuUp.value = false
}
} }
function closeActionsMenu() { function closeActionsMenu() {
@@ -593,6 +647,14 @@ function onRecover(id: string) {
emit('recover', id) emit('recover', id)
} }
// ── 走查批E:状态点 title(三处列表态共用;映射优先级与状态点 class 一致)──
function convStatusTitle(conv: AiConversationSummary): string {
if (store.isGenerating(conv.id)) return t('aiChat.generating')
if (props.pendingCountOf(conv.id) > 0) return t('aiChat.statusPendingApproval')
if (conv.loop_state === 'parked') return t('aiChat.statusParked')
return t('aiChat.statusIdle')
}
onMounted(() => { onMounted(() => {
// 点击外部关闭更多操作菜单 // 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick) document.addEventListener('click', onActionsOutsideClick)
@@ -764,6 +826,16 @@ onBeforeUnmount(() => {
.ai-conv-item--recoverable { .ai-conv-item--recoverable {
cursor: pointer; cursor: pointer;
} }
/* 走查批E:恢复进行中行级 busy 态(降透明+禁命中示不可点;spinner 由 .ai-btn-spinner 全局动画承接) */
.ai-conv-item--recover-busy {
pointer-events: none;
opacity: 0.6;
}
.ai-recover-spin {
flex-shrink: 0;
display: inline-flex;
color: var(--df-warning);
}
.ai-conv-recover-badge { .ai-conv-recover-badge {
flex-shrink: 0; flex-shrink: 0;
margin-left: 2px; margin-left: 2px;
@@ -868,6 +940,13 @@ onBeforeUnmount(() => {
border-radius: var(--df-radius-sm); border-radius: var(--df-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
} }
/* 走查批E:列表后半触发时向上翻(防底部被 .ai-conv-list overflow 裁剪),翻向由 menuUp 判定 */
.ai-conv-actions-menu--up {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 2px;
}
.ai-conv-actions-opt { .ai-conv-actions-opt {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -910,4 +989,24 @@ onBeforeUnmount(() => {
background: var(--df-bg-card-hover); background: var(--df-bg-card-hover);
color: var(--df-text); color: var(--df-text);
} }
/* 走查批E:加载失败一次性态(danger 染色提示可点重试;下次点击在 composable 入口复位) */
.ai-conv-load-more--fail {
color: var(--df-danger);
}
.ai-conv-load-more--fail:hover {
color: var(--df-danger);
}
/* ═══ 走查批E:窄侧栏容器查询(容器=ConversationSidebar 根 container-type: inline-size,
对齐 KnowledgeDetail @container 范式)。≤160px 档:时间戳隐藏(标题优先),徽标间距收紧 ═══ */
@container (max-width: 160px) {
.ai-conv-item-time {
display: none;
}
.ai-conv-item-main {
gap: 4px;
}
.ai-conv-item-title {
gap: 2px;
}
}
</style> </style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<!-- ── L3 完整上下文面板 ──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(info=ContextInfo)由父派生传入,
展开态由父持有(五面板互斥)。 -->
<div v-if="info.goals.length || info.summaryCount > 0" class="ai-context-inline">
<button type="button" class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewContext')" :aria-label="$t('aiChat.viewContext')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-context-inline-list">
<!-- 系统提示 -->
<div class="ai-context-section">
<div class="ai-context-section-title">{{ $t('aiChat.contextSystemPrompt') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.goalsCount', { n: info.goals.length }) }}</span>
<span class="ai-context-value">{{ info.goals.join('; ') || $t('aiChat.none') }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.contextEnvironment') }}</span>
<span class="ai-context-value">{{ info.osInfo }}</span>
</div>
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.contextCompressedSummary') }}</span>
<span class="ai-context-value">{{ info.summaryCount }} {{ $t('aiChat.contextCountUnit') }}</span>
</div>
</div>
</div>
<!-- 增强上下文 -->
<div class="ai-context-section">
<div class="ai-context-section-title">{{ $t('aiChat.contextEnrichment') }}</div>
<div class="ai-context-section-body">
<div class="ai-context-row">
<span class="ai-context-label">{{ $t('aiChat.projectLabel') }}</span>
<span class="ai-context-value">{{ info.projectNames || $t('aiChat.none') }}</span>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { ContextInfo } from './types'
defineProps<{
/** 完整上下文数据(父 computed:conv.pinned_goals + 摘要计数 + 项目名) */
info: ContextInfo
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('context') 做互斥编排) */
(e: 'toggle'): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ═══ L3 完整上下文(底部) ═══ */
.ai-context-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-context-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 2px;
}
.ai-context-section {
padding: 6px 10px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-context-section:last-child { border-bottom: none; }
.ai-context-section-title {
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 4px;
}
.ai-context-section-body {
display: flex;
flex-direction: column;
gap: 3px;
}
.ai-context-row {
display: flex;
gap: 6px;
font-size: 11px;
}
.ai-context-label {
flex-shrink: 0;
color: var(--df-text-dim);
min-width: 56px;
}
.ai-context-value {
color: var(--df-text-secondary);
word-break: break-all;
}
/* context 面板补 max-height 滚动(原无上限,增强上下文多时同理) */
.ai-context-inline-list {
max-height: 360px;
overflow-y: auto;
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+182
View File
@@ -0,0 +1,182 @@
<template>
<!-- 🎯 对话目标面板(对话透明化 L1:Goal visibility)。
c1c 拆分自 TopBar.vue,零行为变更。目标加载/删除/回写 store 自含;
展开态由父持有(五面板互斥),清空目标时经 close 事件请求收起(原 :918 语义)。 -->
<div v-if="goals.length" class="ai-goals-inline">
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewGoals')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
<span v-else class="ai-goal-check">✓</span>
<span class="ai-goal-text">{{ g.text }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { aiApi } from '../../../api/ai'
import { useAiStore } from '../../../stores/ai'
import type { GoalEntry } from '../../../api/types'
defineProps<{
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('goals') 做互斥编排) */
(e: 'toggle'): void
/** 请求收起面板(目标清空时;父置 expanded=false) */
(e: 'close'): void
}>()
const store = useAiStore()
const goals = ref<GoalEntry[]>([])
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标 */
watch(() => store.state.messages.length, () => {
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
function loadGoals(convId: string | null) {
if (!convId) { goals.value = []; return }
const conv = store.state.conversations.find(c => c.id === convId)
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
const raw = conv?.pinned_goals ?? []
goals.value = raw.map((g: any) =>
typeof g === 'string' ? { text: g, status: 'active' as const } : g
)
}
/** 移除第 i 个目标 */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const prev = goals.value
const next = prev.filter((_, idx) => idx !== i)
goals.value = next
try {
// 跨端修复(走查批E):原 next.map(g => g.text) 把 GoalEntry 降级 string[],后端逐条
// GoalEntry::new 重置 active,已完成目标(status='completed')的完成态被抹。改传完整
// GoalEntry[](text/status 原样透传,snake_case),与后端 ai_update_conversation_goals
// 的 Vec<GoalEntry> 反序列化字段一一对应。
await aiApi.updateConversationGoals(convId, next)
} catch (e) {
// 失败回滚乐观删除(对齐 useAiConversations.withConvOp 回写范式),面板不收
goals.value = prev
console.error('[goals] 更新目标列表失败,已回滚:', e)
return
}
if (next.length === 0) emit('close')
// UX 走查-对话域 B4:删除后同步回写 store 的 conv.pinned_goals(对齐 AiCompleted 回写
// useAiLifecycleEvents:126-129 的结构)。不回写则 loadGoals 从 store 重读旧列表,
// 已删目标在下次 messages.length watch 触发刷新时"复活"。
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) conv.pinned_goals = next
}
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* 目标计数(底部) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-goals-inline-list {
position: absolute;
top: 100%;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-goal-inline-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card);
}
.ai-goal-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
.ai-goal-remove:hover {
color: var(--df-danger);
}
/* goals 面板补 max-height 滚动(原无上限,目标多时无限拉高遮对话) */
.ai-goals-inline-list {
max-height: 300px;
overflow-y: auto;
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<!-- TopBar 头部左侧区(侧栏开关 + 助手标题 + 审批角标 + 挂机引擎态徽章)。
自 TopBar.vue c1c 拆分迁入,零行为变更;挂机轮询/熔断复位/设置跳转随区块整体迁移。 -->
<div class="ai-header-left">
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')" :aria-label="$t('aiChat.conversationList')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="ai-header-icon">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</span>
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
<button
v-if="pendingApprovalCount > 0"
type="button"
class="ai-approval-badge df-badge df-badge--danger"
:title="$t('aiChat.pendingApprovalHint')"
@click="emit('scroll-to-pending')"
>
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</button>
<!-- 挂机引擎态徽章:开启=「挂机中」(在途附「执行中」),熔断=红徽点击复位;关闭态不显示 -->
<button
v-if="unattendedStatus?.breaker_tripped"
type="button"
class="ai-unattended-badge df-badge df-badge--danger"
:title="$t('aiChat.unattendedResetHint')"
@click="resetUnattendedBreaker"
>
{{ $t('aiChat.unattendedTripped') }}
</button>
<button
v-else-if="unattendedStatus?.enabled"
type="button"
class="ai-unattended-badge df-badge df-badge--accent"
:title="$t('aiChat.unattendedBadgeHint')"
@click="jumpToUnattendedSettings"
>
🛌 {{ $t('aiChat.unattendedOn') }}<template v-if="unattendedStatus.in_flight_conv"> · {{ $t('aiChat.unattendedExecuting') }}</template>
</button>
</div>
</template>
<script setup lang="ts">
// ── 挂机引擎态徽章(P1 总装):消费 unattended_status,开启/熔断时 30s 级轮询刷新 ──
// 关闭态不显示徽章,轮询降频到 2 分钟(仍能感知设置页里的开启动作;单次 KV 级纯读,开销可忽略)
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import { useAppSettingsStore } from '../../../stores/appSettings'
defineProps<{
/** 待审批数(审批角标显隐 + 计数;父 computed) */
pendingApprovalCount: number
}>()
const emit = defineEmits<{
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
(e: 'toggle-sidebar'): void
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
(e: 'scroll-to-pending'): void
}>()
interface UnattendedEngineStatus {
enabled: boolean
claim_scope: string
in_flight_conv: string | null
breaker_tripped: boolean
poll_secs: number
}
const UNATTENDED_ACTIVE_MS = 30_000
const UNATTENDED_IDLE_MS = 120_000
const router = useRouter()
const appSettings = useAppSettingsStore()
const unattendedStatus = ref<UnattendedEngineStatus | null>(null)
let unattendedTimer: ReturnType<typeof setTimeout> | null = null
// 卸载守卫:onBeforeUnmount 清定时器后,在途 invoke 的续延还会回来排下一轮,须拦住防卸载后空转
let unattendedDisposed = false
async function refreshUnattended() {
if (unattendedDisposed) return
try {
unattendedStatus.value = await invoke<UnattendedEngineStatus>('unattended_status')
} catch { /* IPC 失败保留上次状态,下轮重试 */ }
if (unattendedDisposed) return
const s = unattendedStatus.value
const delay = s?.enabled || s?.breaker_tripped ? UNATTENDED_ACTIVE_MS : UNATTENDED_IDLE_MS
unattendedTimer = setTimeout(() => void refreshUnattended(), delay)
}
/** 点击徽章跳设置页挂机区块(集成类首面板;activeCategory 由 appSettings 持久化驱动) */
function jumpToUnattendedSettings() {
void appSettings.set('df-settings-active-category', 'integrations')
void router.push('/settings')
}
/** 熔断复位:开关 false→true(引擎在关态轮询里清熔断与失败计数,再开即恢复领单) */
async function resetUnattendedBreaker() {
try {
await invoke('unattended_set_enabled', { enabled: false })
await invoke('unattended_set_enabled', { enabled: true })
} catch (e) {
console.warn('[topbar] 挂机熔断复位失败:', e)
}
void refreshUnattended()
}
onMounted(() => { void refreshUnattended() })
onBeforeUnmount(() => {
unattendedDisposed = true
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
})
</script>
<style scoped>
.ai-header-left {
display: flex;
align-items: center;
gap: 6px;
}
.ai-header-icon {
display: flex;
align-items: center;
color: var(--df-accent);
}
.ai-header-title {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
.ai-approval-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
transition: opacity 0.15s var(--df-ease);
appearance: none;
}
.ai-approval-badge:hover {
opacity: 0.85;
}
.ai-approval-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
.ai-unattended-badge {
display: inline-flex;
align-items: center;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
appearance: none;
transition: opacity 0.15s var(--df-ease);
}
.ai-unattended-badge:hover {
opacity: 0.85;
}
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<!-- ── 📋 历史消息面板(当前会话 user 消息列表)──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(items)与定位未命中提示(locateMiss)
由父传入;点击/回车/空格经 navigate 回父执行滚动定位(命中收 history 面板语义在父)。 -->
<div v-if="items.length" class="ai-history-inline">
<button type="button" class="ai-history-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewHistory')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ items.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-history-inline-list">
<div v-for="h in items" :key="h.id" class="ai-history-inline-item" role="button" tabindex="0" @click="emit('navigate', h.id)" @keydown.enter.prevent="emit('navigate', h.id)" @keydown.space.prevent="emit('navigate', h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
<!-- F6-1:目标消息在折叠段内未渲染,面板不收 + 行内提示 -->
<div v-if="locateMiss" class="ai-locate-miss">{{ locateMiss }}</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { HistoryItem } from './types'
defineProps<{
/** 历史消息项(父 msgDerived 单遍派生) */
items: HistoryItem[]
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
/** F6-1 定位未命中提示(空串=不显示;父 locateMissHint) */
locateMiss: string
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('history') 做互斥编排) */
(e: 'toggle'): void
/** 点击历史消息项 → 父滚动定位到对应消息气泡 */
(e: 'navigate', msgId: string): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* 历史消息(底部) */
.ai-history-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-history-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 240px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-history-inline-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--df-text-secondary);
transition: background 0.1s;
}
.ai-history-inline-item:hover {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-history-index {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
min-width: 16px;
text-align: left;
font-family: var(--df-font-mono);
}
.ai-history-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.ai-history-time {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
<div class="ai-more-menu" ref="moreMenuRef">
<button
class="ai-btn-icon"
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
:title="$t('aiChat.moreActions')"
:aria-label="$t('aiChat.moreActions')"
@click="moreMenuOpen = !moreMenuOpen"
>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Transition name="ai-more-popout">
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming"
@click="emit('clear-context'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<span>{{ $t('aiChat.clearContext') }}</span>
</button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
@click="emit('compress-context'); moreMenuOpen = false"
>
<svg aria-hidden="true" v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg aria-hidden="true" v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
</button>
<!-- I12(2026-10-05):快捷键速查入口(零快捷键提示补课;面板体在 AiChat Teleport 渲染) -->
<button
class="ai-more-item"
@click="emit('show-shortcuts'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6.01" y2="10"/><line x1="10" y1="10" x2="10.01" y2="10"/><line x1="14" y1="10" x2="14.01" y2="10"/><line x1="18" y1="10" x2="18.01" y2="10"/><line x1="7" y1="14" x2="7.01" y2="14"/><line x1="11" y1="14" x2="11.01" y2="14"/><line x1="15" y1="14" x2="15.01" y2="14"/></svg>
<span>{{ $t('aiChat.shortcutsTitle') }}</span>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// TopBar 更多菜单 popout(c1c 拆分自 TopBar.vue,零行为变更):
// 低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起,ESC 收起(键盘友好)。
// 动作经 emit 回父(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAiStore } from '../../../stores/ai'
defineProps<{
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
hasActiveMessages: boolean
}>()
const emit = defineEmits<{
/** 清空上下文(归档保留;父 handleClearContext) */
(e: 'clear-context'): void
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
(e: 'compress-context'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
}>()
const store = useAiStore()
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
const moreMenuOpen = ref(false)
const moreMenuRef = ref<HTMLElement | null>(null)
function onDocClickCloseMoreMenu(e: MouseEvent) {
if (!moreMenuOpen.value) return
const root = moreMenuRef.value
if (root && !root.contains(e.target as Node)) {
moreMenuOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocClickCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('click', onDocClickCloseMoreMenu))
// ESC 键收起更多菜单(键盘友好)
function onEscCloseMoreMenu(e: KeyboardEvent) {
if (e.key === 'Escape' && moreMenuOpen.value) moreMenuOpen.value = false
}
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
</script>
<style scoped>
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
.ai-more-menu { position: relative; } /* popout absolute 定位基准 */
.ai-more-popout {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 168px;
padding: 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: var(--df-shadow-sm);
z-index: 100;
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
</style>
@@ -0,0 +1,220 @@
<template>
<!-- TopBar 头部中间区:provider 状态 + 模型选择器。自 TopBar.vue c1c 拆分迁入,零行为变更。
provider/model 选择逻辑直接读 store(单例共享);cycleProvider 后经 provider-switched
回父(父负责弹临时 bar + 向 AiChat 转发 toast,保持单一 toast 源)。 -->
<div class="ai-header-center">
<button
type="button"
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
@click="cycleProvider"
>
<span class="df-dot provider-dot" :class="'provider-dot--' + providerStatus"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</button>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useAiStore } from '../../../stores/ai'
import type { ModelConfig } from '../../../api/types'
defineProps<{
/** 当前生效 provider 名(父 computed;切换临时 bar 同用一份) */
activeProviderName: string
}>()
const emit = defineEmits<{
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void
}>()
const store = useAiStore()
/** provider 连通状态(零侵入,基于最后一条 assistant 消息 isError 推断):
* unknown=未发过(灰) / ok=最近一次 AI 请求成功(绿) / error=最近失败(红,如 401/超时/服务不通)。
* 不主动 ping(省配额),反映最近一次实际请求结果。切换 provider 后状态保留直到新请求。 */
const providerStatus = computed<'unknown' | 'ok' | 'error'>(() => {
const msgs = store.state.messages
for (let i = msgs.length - 1; i >= 0; i--) {
const m = msgs[i]
if (m.role === 'assistant') return m.isError ? 'error' : 'ok'
}
return 'unknown'
})
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
|| store.state.providers[0]
|| null,
)
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
const selectedModelId = computed(() => store.modelOverride.value || '')
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// 切换会话:仅当手选模型空/对目标模型池无效时才回落权重最高(保留用户手选,不每次重置)
watch(() => store.state.activeConversationId, () => {
const current = store.modelOverride.value
const valid = current && enabledModels.value.some(m => m.model_id === current)
if (!valid) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
})
// 切 provider 后模型 override 同步(修复:provider 切换后 header 模型名不刷新):
// - modelOverride 是模块级 ref(useAiSend)不持久化,切 provider 后仍持旧 provider 的 model_id,
// 该 id 不在新 provider 池里 → 下拉显示错位(UI 显示 X,实际后端用路由默认 Y)。
// - 守卫:当前选择对新 provider 模型池无效(空/不在池)时,默认选权重最高模型。
// enabledModels 已按 weight 降序排,model[0] 即权重最高。
// - immediate:组件挂载时也校验(modelOverride 为 null → 默认权重最高,与下拉首项对齐)。
watch(enabledModels, (models) => {
const current = store.modelOverride.value
const valid = current && models.some(m => m.model_id === current)
if ((!valid || !current) && models.length) {
store.modelOverride.value = models[0].model_id
}
}, { immediate: true })
// 循环切换 Provider(provider bar 点击)
function cycleProvider() {
const ps = store.state.providers
if (ps.length < 2) return
// activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0]
const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
const idx = startId ? ps.findIndex(p => p.id === startId) : -1
// idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到)
const next = ps[(Math.max(idx, 0) + 1) % ps.length]
store.setProvider(next.id)
// 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。
// toast 机制:复用父自管 reactive showToast(分离窗口无 App.vue 根 toast),经 emit 回父弹。
emit('provider-switched', next.name || next.id)
}
</script>
<style scoped>
/* header 中间区:provider + 模型选择器并排 */
.ai-header-center {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
min-width: 0;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
font-size: 10px;
font-family: var(--df-font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
}
/* 聚焦可见环:聚焦态无边框变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
.ai-model-select:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
.ai-model-select--compact {
max-width: 130px;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-model-empty {
font-size: 10px;
color: var(--df-text-dim);
white-space: nowrap;
}
/* ═══ Provider Bar (header 中间区)
2026-10-18 wave2:对齐 workbench .session-status pill 范式——bg-card 底+边框+8px 圆角+12px 字,
状态点迁 .df-dot(7px 尺寸由全局公共类提供);div→button 后归零默认样式 ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--df-text-secondary);
white-space: nowrap;
font-family: inherit;
appearance: none;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
padding: 4px 12px;
transition: border-color 0.15s var(--df-ease);
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
border-color: var(--df-border-strong);
color: var(--df-text);
}
.ai-provider-bar--switchable:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 2px;
border-radius: var(--df-radius-md);
}
/* provider 连通状态点:灰=未测/绿=最近成功/红=最近失败(基于最后 assistant 消息 isError);
尺寸/圆角走全局 .df-dot,此处只管色 */
.provider-dot { flex-shrink: 0; transition: background .2s; background: var(--df-text-dim); }
.provider-dot--ok { background: var(--df-success); }
.provider-dot--error { background: var(--df-danger); }
.provider-name {
font-size: 12px;
font-family: var(--df-font-mono);
color: var(--df-text-secondary);
}
.ai-provider-empty {
background: var(--df-danger-bg);
}
.provider-hint {
font-size: 11px;
color: var(--df-danger);
}
</style>
@@ -0,0 +1,55 @@
<template>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出)。
c1c 拆分自 TopBar.vue,零行为变更。纯展示:显隐(visible)与 provider 名由父持有;
仍渲染于底部工具区之后(与拆分前 DOM 位置一致)。 -->
<Transition name="ai-provider-switch">
<div v-if="visible" class="ai-provider-switch-bar">
<span class="ai-provider-switch-dot"></span>
<span class="ai-provider-switch-text">{{ $t('aiChat.providerSwitchHint', { model: providerName }) }}</span>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
/** bar 显隐(父 providerBarVisible,2s 自动收) */
visible: boolean
/** 当前生效 provider 名(父 computed,与 header provider bar 同一份) */
providerName: string
}>()
</script>
<style scoped>
/* Provider 切换临时 bar(淡入淡出 0.15s) */
.ai-provider-switch-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 25%, transparent);
font-size: 11px;
color: var(--df-accent);
user-select: none;
}
.ai-provider-switch-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-accent);
flex-shrink: 0;
animation: ai-agentic-pulse 1s ease-in-out infinite;
}
.ai-provider-switch-text {
font-family: var(--df-font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-provider-switch-enter-active,
.ai-provider-switch-leave-active { transition: opacity 0.15s var(--df-ease), max-height 0.15s var(--df-ease); }
.ai-provider-switch-enter-from,
.ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
.ai-provider-switch-enter-to,
.ai-provider-switch-leave-from { opacity: 1; max-height: 32px; }
</style>
@@ -0,0 +1,314 @@
<template>
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回)。
c1c 拆分自 TopBar.vue,零行为变更。面板展开态由父持有(五面板互斥编排),
本子件经 toggle/close 事件回报;展开时拉取可恢复清单(原 togglePanel B3 分支语义)。 -->
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
<button type="button" class="ai-running-tasks-badge ai-tool-badge df-badge df-badge--info" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.runningTasks')">
<span class="ai-running-tasks-pulse"></span>
{{ runningTasks.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-running-tasks-list">
<button v-for="task in runningTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
<span class="ai-running-task-round">{{ t('aiChat.runningRounds', { n: task.rounds }) }}</span>
</button>
<template v-if="recoverableTasks.length">
<div class="ai-running-tasks-sep"></div>
<div v-for="task in recoverableTasks" :key="task.id" class="ai-running-task-item ai-running-task-item--parked">
<button type="button" class="ai-running-task-main" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot ai-running-task-dot--parked"></span>
<span class="ai-running-task-title">{{ task.title }}</span>
</button>
<button type="button" class="ai-running-task-recover" @click="recoverParked(task.id)" :title="t('aiChat.recoverNow')">{{ t('aiChat.recoverNow') }}</button>
</div>
</template>
</div>
</Transition>
<!-- 走查批E:恢复失败就近 toast(原仅 console.warn 用户无感;对齐侧栏 recoverableFail 键) -->
<Transition name="ai-pop">
<div v-if="recoverToast.visible" class="ai-recover-toast">{{ recoverToast.msg }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// TopBar 后台在途/可恢复任务面板(c1c 拆分自 TopBar.vue,零行为变更)。
// 数据源=convRounds(轮次追踪,事件写)× store.isGenerating(状态,同源 convStates),
// 排除当前会话(自身进度已有 agentic 实时进度条,不重复)。响应式:convRounds 是
// aiShared 模块级 reactive Map,computed 内遍历即建依赖。
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { aiApi } from '../../../api/ai'
import { convRounds } from '../../../composables/ai/aiShared'
import { switchConversation } from '../../../composables/ai/useAiConversations'
import { useToast } from '../../../composables/useToast'
const props = defineProps<{
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('running') 做互斥编排) */
(e: 'toggle'): void
/** 请求收起面板(切回会话时;父置 expanded=false) */
(e: 'close'): void
}>()
const { t } = useI18n()
const store = useAiStore()
const runningTasks = computed(() => {
const items: { id: string; title: string; rounds: number }[] = []
for (const [id, rounds] of convRounds) {
if (id === store.state.activeConversationId) continue
if (!store.isGenerating(id)) continue
const conv = store.state.conversations.find(c => c.id === id)
items.push({ id, title: conv?.title || t('aiChat.untitledConversation'), rounds })
}
return items
})
// V49 可恢复入口(B3):数据源双轨——conversations 中 loop_state='parked'(实时投影,
// 侧栏 limit 内)+ ai_list_recoverable 完备真相 IPC(面板展开时拉取,补侧栏截断漏项)。
// 合并去重按 id;点击切回,继续与否由用户在会话内决定,不做自动续跑
const recoverableFromList = computed(() => {
return store.state.conversations
.filter(c => c.loop_state === 'parked' && c.id !== store.state.activeConversationId)
.map(c => ({ id: c.id, title: c.title || t('aiChat.untitledConversation') }))
})
const recoverableFromIpc = ref<{ id: string; title: string | null }[]>([])
async function refreshRecoverable() {
try {
const items = await aiApi.listRecoverable()
recoverableFromIpc.value = items.map(i => ({ id: i.id, title: i.title }))
} catch { /* IPC 失败退回 list 投影,不阻断面板 */ }
}
const recoverableTasks = computed(() => {
const seen = new Set<string>()
const merged: { id: string; title: string }[] = []
for (const item of [...recoverableFromList.value, ...recoverableFromIpc.value]) {
if (item.id === store.state.activeConversationId || seen.has(item.id)) continue
seen.add(item.id)
merged.push({ id: item.id, title: item.title || t('aiChat.untitledConversation') })
}
return merged.slice(0, 10)
})
// B3:任务面板展开时拉一次可恢复清单完备真相(补侧栏 limit 截断漏项);失败退回 list 投影
// (原 togglePanel('running') 展开分支,拆分后由展开态 watch 承接,触发时机等价)
watch(() => props.expanded, (open) => {
if (open) void refreshRecoverable()
})
async function switchToRunning(id: string) {
emit('close')
if (id !== store.state.activeConversationId) {
await switchConversation(id)
}
}
/** B4:恢复 parked 会话(注入恢复引导消息续跑),成功后切回该会话 */
const recoveringIds = ref(new Set<string>())
// 走查批E:恢复失败就近 toast(本组件自持容器,父 AiChat 仍为唯一聊天流 toast 源)
const { toast: recoverToast, showToast: showRecoverToast } = useToast()
async function recoverParked(id: string) {
if (recoveringIds.value.has(id)) return
recoveringIds.value.add(id)
try {
await aiApi.recoverParked(id)
recoveringIds.value.delete(id)
await switchToRunning(id)
} catch (e) {
recoveringIds.value.delete(id)
console.warn('[topbar] 恢复失败:', e)
showRecoverToast(t('aiChat.recoverableFail', { msg: String(e) }), 'error')
}
}
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ── #14 桌面任务卡:后台在途任务徽章+面板 ── */
.ai-running-tasks-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-running-tasks-badge {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* wave2:后台会话徽章迁 .df-badge--info(透明彩底 info 系);scoped 需显式覆写压过 .ai-tool-badge 灰底 */
.ai-running-tasks-badge.df-badge--info {
background: var(--df-info-bg);
color: var(--df-info);
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
}
.ai-running-tasks-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success);
animation: ai-running-pulse 2s ease-in-out infinite;
}
@keyframes ai-running-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ai-running-tasks-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 260px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
}
.ai-running-task-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-item:hover {
background: var(--df-bg-card-hover);
}
.ai-running-task-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-success);
}
.ai-running-task-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-running-task-round {
flex-shrink: 0;
font-size: 11px;
color: var(--df-text-secondary);
}
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
.ai-running-tasks-sep {
height: 1px;
margin: 4px 8px;
background: var(--df-border);
}
.ai-running-task-dot--parked {
background: var(--df-warning);
}
/* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
.ai-running-task-item--parked {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
}
.ai-running-task-item--parked:hover {
background: none;
}
.ai-running-task-main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 5px 0 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-main:hover {
background: var(--df-bg-card-hover);
}
.ai-running-task-recover {
flex-shrink: 0;
margin-right: 10px;
padding: 2px 8px;
border: 1px solid var(--df-warning);
border-radius: var(--df-radius-sm);
background: none;
cursor: pointer;
font-size: 11px;
color: var(--df-warning);
}
.ai-running-task-recover:hover {
background: var(--df-warning);
color: #fff;
}
/* 走查批E:恢复失败就近 toast(容器内下方弹出,视觉对齐侧栏 .ai-conv-toast,全 token) */
.ai-recover-toast {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 110; /* 高于面板列表(100),失败时面板仍展开不被盖 */
padding: 6px 10px;
border-radius: var(--df-radius);
font-size: 11px;
max-width: 280px;
overflow-wrap: anywhere;
box-shadow: var(--df-shadow-sm);
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
+139
View File
@@ -0,0 +1,139 @@
<template>
<!-- ── 📄 上下文摘要面板(压缩/归档的系统消息)──
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(items)与定位未命中提示(locateMiss)
由父传入;点击项经 navigate 回父执行滚动定位。 -->
<div v-if="items.length" class="ai-summary-inline">
<button type="button" class="ai-summary-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewSummary')">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ items.length }}
</button>
<Transition name="ai-pop">
<div v-if="expanded" class="ai-summary-inline-list">
<div v-for="(s, i) in items" :key="i" class="ai-summary-inline-item" role="button" tabindex="0" @click="emit('navigate', s.id)" @keydown.enter.prevent="emit('navigate', s.id)" @keydown.space.prevent="emit('navigate', s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') }} #{{ items.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
<!-- F6-1:摘要项同享定位未命中提示(压缩段折叠时 system 消息同样不在 DOM) -->
<div v-if="locateMiss" class="ai-locate-miss">{{ locateMiss }}</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { SummaryItem } from './types'
defineProps<{
/** 摘要消息项(父 msgDerived 单遍派生,与 historyMsgs 共享单次遍历) */
items: SummaryItem[]
/** 面板展开态(父持有,五面板互斥) */
expanded: boolean
/** F6-1 定位未命中提示(空串=不显示;父 locateMissHint) */
locateMiss: string
}>()
const emit = defineEmits<{
/** 徽章点击(父 togglePanel('summary') 做互斥编排) */
(e: 'toggle'): void
/** 点击摘要项 → 父滚动定位到对应消息气泡 */
(e: 'navigate', msgId: string): void
}>()
</script>
<style scoped>
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
line-height: 16px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
vertical-align: middle;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
font-family: inherit;
font-weight: normal;
appearance: none;
}
.ai-tool-badge:focus-visible {
outline: 1.5px solid var(--df-accent);
outline-offset: 1px;
}
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
.ai-tool-badge.is-active {
color: var(--df-accent);
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
}
/* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-summary-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0;
margin-top: 2px;
}
.ai-summary-inline-item {
padding: 6px 10px;
cursor: pointer;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.ai-summary-inline-item:last-child { border-bottom: none; }
.ai-summary-inline-item:hover {
background: var(--df-bg-card);
}
.ai-summary-label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 2px;
}
.ai-summary-preview {
margin: 0;
font-size: 11px;
color: var(--df-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* F6-1:历史/摘要定位未命中提示(目标消息在折叠段内,引导展开分隔条) */
.ai-locate-miss {
padding: 6px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--df-warning);
}
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
.ai-pop-enter-from,
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
</style>
@@ -0,0 +1,226 @@
<template>
<!-- 无人值守任务进度(AiTaskProgress,设计 §4.3):当前会话有进度快照时显示,
静默期(子代理长跑/大工具执行/重入退避)凭最后快照可见「在干活」;终局即清。
max_rounds=0(不限轮次)不显分母进度条,只显轮次与 token(防假进度规则2:无分母禁画百分比)。
U2 三层(设计 §三):活性=脉冲点(静默降速,永不伪装停止);位置=轮次/token 数字;
内容=阶段标签(最近工具映射)+已完成步数,静默档附加判断材料(不自动判死,决策留给用户)。
A8 双进度矛盾:输入区 agentic 实时进度条可见时(streaming 且 agentRound>0)
隐藏本快照徽章——两处同屏且轮数口径不同(实时 vs 每5轮快照)互相矛盾;
快照徽章只服务输入区不可见场景(托盘/单轮大工具静默期)。 -->
<div
v-if="taskProgress && !agenticProgressVisible"
class="ai-task-progress"
:class="silenceTier !== 'normal' ? `ai-task-progress--${silenceTier}` : ''"
:title="taskProgressHint"
>
<span class="ai-task-progress-dot"></span>
<span class="ai-task-progress-text">{{ taskProgressText }}</span>
<span v-if="stageText" class="ai-task-progress-stage">{{ stageText }}</span>
<span v-if="silenceSuffix" class="ai-task-progress-silence">{{ silenceSuffix }}</span>
<span
v-if="taskProgress.maxRounds > 0"
class="ai-task-progress-bar"
:title="$t('ai.taskProgress.barHint', { round: taskProgress.round, max: taskProgress.maxRounds })"
>
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
</span>
</div>
</template>
<script setup lang="ts">
// TopBar 无人值守快照徽章(c1c 拆分自 TopBar.vue,零行为变更):
// 消费 useAiEvents 归集的 pendingTaskProgress(per-conv Map,终局即清):无快照不渲染。
// 切会话自动跟随(Map 按 activeId 取),多会话并发各显各的,不串台。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../../stores/ai'
import { pendingTaskProgress } from '../../../composables/ai/useAiPendingState'
const { t } = useI18n()
const store = useAiStore()
const taskProgress = computed(() => {
const id = store.state.activeConversationId
if (!id) return null
return pendingTaskProgress.get(id) ?? null
})
// A8 双进度矛盾:输入区 agentic 实时进度条可见条件(与 AiChat.vue showAgenticProgress 同口径)——
// 可见时 TopBar 快照徽章让位隐藏(轮数口径不同:实时 vs 每5轮快照,同屏互相矛盾)
const agenticProgressVisible = computed(() =>
store.state.streaming && store.state.agentRound > 0,
)
const taskProgressText = computed(() => {
const p = taskProgress.value
if (!p) return ''
const tokens = p.tokensUsed >= 1000 ? `${(p.tokensUsed / 1000).toFixed(1)}k` : String(p.tokensUsed)
return p.maxRounds > 0
? t('ai.taskProgress.running', { round: p.round, max: p.maxRounds, tokens })
: t('ai.taskProgress.unlimited', { round: p.round, tokens })
})
const taskProgressPct = computed(() => {
const p = taskProgress.value
if (!p || p.maxRounds <= 0) return 0
return Math.min(100, Math.round((p.round / p.maxRounds) * 100))
})
// ── U2 进度语义三层补齐(设计 §三):内容层(最近动作)+静默期三档(§3.4) ──
// 规则1 兑现:下方定时器只算「距上次信号多久」,永不推进任何计数(轮次/步数只由事件写)。
// 规则4 兑现:活性=脉冲节奏(静默降速),位置=数字,分开渲染;永不自动判死(重入退避最长
// 15min 是合法等待),警示档只给判断材料,「疑似卡死可停止」的决策留给用户。
// 内容层数据源:工具卡挂在 assistant 消息上(useAiToolEvents),从消息流纯派生——零新事件
// 监听零协议改动。steps=当前消息流工具调用总数(压缩仅标记不清消息,计数不回退;清空上下文
// 重置属用户显式重开,语义自洽);last 优先取 running 卡(当前动作),无 running 取最后一张。
const toolActivity = computed(() => {
let steps = 0
let lastName: string | null = null
let runningName: string | null = null
for (const m of store.state.messages) {
const tcs = m.toolCalls
if (!tcs?.length) continue
steps += tcs.length
for (const tc of tcs) {
lastName = tc.name
if (tc.status === 'running') runningName = tc.name
}
}
return { steps, last: runningName ?? lastName }
})
// 阶段标签映射(设计 §3.3 同源词表桌面份):工具名→语义标签;未命中直显工具名
const STAGE_KEYS: Record<string, string> = {
read_file: 'read', read_symbol: 'read', search_files: 'read', list_directory: 'read',
write_file: 'edit', patch_file: 'edit',
run_command: 'run',
spawn_agent: 'agent',
}
const STAGE_PREFIXES: [string, string][] = [['knowledge_', 'knowledge'], ['idea_', 'knowledge'], ['task_', 'knowledge']]
function stageKeyOf(name: string): string | null {
if (STAGE_KEYS[name]) return STAGE_KEYS[name]
for (const [p, k] of STAGE_PREFIXES) if (name.startsWith(p)) return k
return null
}
const stageText = computed(() => {
const act = toolActivity.value
if (!act.last) return ''
const key = stageKeyOf(act.last)
const parts = [key ? t(`ai.taskProgress.stage.${key}`) : act.last]
if (act.steps > 0) parts.push(t('ai.taskProgress.steps', { n: act.steps }))
return parts.join(' · ')
})
// 静默期计时:基准=最近一次真实信号(AiTaskProgress.updatedAt 或消息流指纹变化时刻)。
// 指纹 watch 只在信号真实变化时刷新(规则5:同值重刷不产生新活性);文本增量/工具卡增减/
// 轮次推进都会被捕获。
const lastActivityAt = ref(Date.now())
watch(() => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
return [msgs.length, last?.id, last?.content?.length, last?.toolCalls?.length, store.state.agentRound]
}, () => { lastActivityAt.value = Date.now() })
// 切会话用已知时间锚点重置(快照 updatedAt/末条消息时间),防「刚切回就看不到本就持续的静默」
watch(() => store.state.activeConversationId, () => {
const msgs = store.state.messages
const last = msgs[msgs.length - 1]
lastActivityAt.value = Math.max(taskProgress.value?.updatedAt ?? 0, last?.timestamp ?? 0)
})
// 15s 心跳只为刷新「距上次信号」展示(§3.4 档位阈值 2/10min,15s 粒度足够;规则1:定时器
// 不碰任何计数)
const nowTick = ref(Date.now())
let silenceTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
silenceTimer = setInterval(() => { nowTick.value = Date.now() }, 15_000)
})
onBeforeUnmount(() => {
if (silenceTimer) { clearInterval(silenceTimer); silenceTimer = null }
})
const silenceSecs = computed(() => {
const p = taskProgress.value
if (!p) return 0
return Math.max(0, Math.floor((nowTick.value - Math.max(p.updatedAt, lastActivityAt.value)) / 1000))
})
// §3.4 三档:<2min 正常 / 2-10min 长执行(脉冲降速) / >10min 警示(给判断材料不自动判死)
const silenceTier = computed<'normal' | 'long' | 'warn'>(() => {
const s = silenceSecs.value
if (s >= 600) return 'warn'
if (s >= 120) return 'long'
return 'normal'
})
const silenceSuffix = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') return t('ai.taskProgress.silentShort', { n: Math.floor(silenceSecs.value / 60) })
if (silenceTier.value === 'long') return t('ai.taskProgress.longRun')
return ''
})
// 悬浮提示:警示档给完整判断材料(距上次活动 n 分钟+最后动作+出路),长执行档给成因提示,
// 正常档不弹(位置信息正文已有,避免无意义提示)
const taskProgressHint = computed(() => {
if (!taskProgress.value) return ''
if (silenceTier.value === 'warn') {
return t('ai.taskProgress.silentWarn', { n: Math.floor(silenceSecs.value / 60), tool: toolActivity.value.last ?? '-' })
}
if (silenceTier.value === 'long') return t('ai.taskProgress.longRunHint')
return ''
})
</script>
<style scoped>
/* 无人值守任务进度(设计 §4.3):绿点+轮次/token 文本+细进度条,与右侧徽章族同排 */
.ai-task-progress {
display: inline-flex;
align-items: center;
gap: 5px;
margin-right: 4px;
white-space: nowrap;
}
.ai-task-progress-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--df-success);
flex-shrink: 0;
animation: ai-task-progress-pulse 1.6s ease-in-out infinite;
}
.ai-task-progress-text {
font-size: 11px;
color: var(--df-text-secondary);
}
.ai-task-progress-bar {
display: inline-block;
width: 48px;
height: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--df-text-dim) 25%, transparent);
overflow: hidden;
}
.ai-task-progress-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--df-success);
transition: width .4s var(--df-ease);
}
@keyframes ai-task-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* U2 静默期档位(设计 §3.4):长执行/警示=脉冲降速——活性层表达「慢」而非伪装「停」,
不自动判死;内容层/静默档文字用弱化色,视觉从属位置层数字 */
.ai-task-progress--long .ai-task-progress-dot,
.ai-task-progress--warn .ai-task-progress-dot {
animation-duration: 3.2s;
}
/* 警示档(静默>10min)状态点转琥珀:给判断材料,不自动判死 */
.ai-task-progress--warn .ai-task-progress-dot {
background: var(--df-warning);
}
.ai-task-progress-stage {
font-size: 11px;
color: var(--df-text-dim);
}
.ai-task-progress-silence {
font-size: 11px;
color: color-mix(in srgb, var(--df-warning) 70%, var(--df-text-secondary));
}
</style>
@@ -0,0 +1,101 @@
<template>
<!-- TopBar 顶部窗口控制按钮组(c1c 拆分自 TopBar.vue,零行为变更)。
多根 fragment:按钮平铺进父 .ai-header-actions flex,与拆分前 DOM 同构。 -->
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
<template v-if="!detached">
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')" :aria-label="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
<svg aria-hidden="true" v-if="store.state.maximized" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<svg aria-hidden="true" v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('detach')" :title="$t('aiChat.detachWindow')" :aria-label="$t('aiChat.detachWindow')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-panel')" :title="$t('aiChat.closePanel')" :aria-label="$t('aiChat.closePanel')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
<!-- 分离模式:收回主面板 + 吸附 + 置顶 + 关闭 -->
<template v-else>
<!-- V11b(2026-10-05):收回分离窗回主面板(emitReattachToMain → 主窗 reattachPanel),
分离窗自关。与「关闭」区别:关闭仅关窗不恢复主面板,收回=分离→主面板合一 -->
<button class="ai-btn-icon" @click="emit('reattach')" :title="$t('aiChat.reattachToMain')" :aria-label="$t('aiChat.reattachToMain')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M9 12h6"/><path d="M12 9l0 6"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': store.state.docked }" @click="emit('dock')" :title="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')" :aria-label="store.state.docked ? $t('aiChat.undock') : $t('aiChat.dockRight')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>
</button>
<button class="ai-btn-icon" :class="{ 'ai-btn-icon--active': alwaysOnTop }" @click="emit('toggle-always-on-top')" :title="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')" :aria-label="alwaysOnTop ? $t('aiChat.unpin') : $t('aiChat.pinOnTop')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8 2 6 5 6 8v3l-2 2v1h16v-1l-2-2V8c0-3-2-6-6-6z"/><line x1="12" y1="14" x2="12" y2="22"/></svg>
</button>
<button class="ai-btn-icon" @click="emit('close-detached')" :title="$t('aiChat.closeWindow')" :aria-label="$t('aiChat.closeWindow')">
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
</template>
<script setup lang="ts">
import { useAiStore } from '../../../stores/ai'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定按钮组) */
detached?: boolean
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
alwaysOnTop: boolean
}>()
const emit = defineEmits<{
/** 最大化/还原(store.toggleMaximize) */
(e: 'toggle-maximize'): void
/** 分离窗口(store.detachPanel) */
(e: 'detach'): void
/** 关闭面板(store.togglePanel) */
(e: 'close-panel'): void
/** V11b 收回分离窗回主面板(父 emitReattachToMain 广播,主窗 reattachPanel 执行) */
(e: 'reattach'): void
/** 吸附/取消吸附(store.dockDetached) */
(e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void
/** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void
}>()
const store = useAiStore()
</script>
<style scoped>
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
// TopBar 拆分子件共享类型(从 TopBar.vue <script setup> 内联接口上移,零行为变更)。
// 单一来源:父(TopBar)派生数据与子件 props 共用同一形状,避免双处声明漂移。
import type { GoalEntry } from '../../../api/types'
/** 底部「历史消息」面板项(user 消息截断摘要,fmtTime 时间戳) */
export interface HistoryItem { id: string; text: string; time: string; num: number }
/** 底部「上下文摘要」面板项(压缩/归档 system 消息截断摘要) */
export interface SummaryItem { id: string; text: string }
/** L3 完整上下文面板数据(系统提示 + 增强上下文两节) */
export interface ContextInfo {
goals: GoalEntry[]
summaryCount: number
osInfo: string
projectNames: string
}
@@ -0,0 +1,334 @@
<template>
<!-- ═══ 工具结果·读取浏览族(拆自 ToolResultBody.vue C4a,零行为变更) ═══
分支顺序/条件/文案与原 v-else-if 链一字不变(原链第 1-4 支):
read_file / list_directory / search_files / grep -->
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :data-tip="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
</button>
</div>
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
<!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-main">
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
<code class="ai-tool-grep-content">{{ m.content }}</code>
</div>
</div>
</div>
<!-- 默认模式:全文预览 -->
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
</div>
<!-- list_directory 结果:文件树 -->
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :data-tip="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div
v-for="(entry, i) in parsed?.entries"
:key="i"
class="ai-tool-dir-entry"
:class="'ai-tool-dir-entry--' + entry.type"
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
>
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.name }}</span>
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染,修 results 被丢弃致 body 仅显「找到 N 项」) -->
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :data-tip="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
</div>
<div class="ai-tool-dir-entries">
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ entry.path }}</span>
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
</div>
</div>
</div>
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件:行:内容),支持 -C 上下文折叠 -->
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :data-tip="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
</div>
<!-- files_with_matches 模式:仅文件列表 -->
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ f }}</span>
</div>
</div>
<!-- count 模式:文件 + 命中行数 -->
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
<span class="ai-tool-dir-name">{{ c.file }}</span>
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
</div>
</div>
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
<!-- 文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
<div v-else class="ai-tool-grep-matches">
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
<div class="ai-tool-grep-match-main">
<span class="ai-tool-grep-file">{{ m.file }}</span>
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
<code class="ai-tool-grep-content">{{ m.content }}</code>
</div>
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 工具结果·读取浏览族 — 拆自 ToolResultBody.vue(C4a 大文件拆分,零行为变更)。
* 职责:read_file / list_directory / search_files / grep 四支结果渲染(原 v-else-if 链第 1-4 支),
* 自持项级交互:路径点击复制(copyResultPath,shared.ts 共享)、grep 命中计数标签。
* 路径条样式经 <style scoped src="./path-bar.css"> 共享;本组件另持文件预览/目录树/grep 命中行样式。
*/
import { computed } from 'vue'
import {
parseResult,
formatBytes,
formatGrep,
shortPath,
dirIcon,
fileIcon,
searchIcon,
DIR_TRUNCATE_LIMIT,
} from '@/composables/ai/useToolCard'
import type { AiToolCallInfo } from '@/api/types'
import { copyResultPath } from './shared'
const props = defineProps<{
/** 单条工具调用数据(结果展示只读消费) */
tc: AiToolCallInfo
/** read_file 内容级展开(来自父级 ToolCard,原样透传) */
isContentExpanded: boolean
}>()
const emit = defineEmits<{
/** read_file "展开全部/收起" → 透传父级(由 ToolCard 继续冒泡) */
'expand-content': [id: string]
}>()
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result))
/**
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
*/
const grepHitLabel = computed(() => {
const r = parsed.value
return r ? formatGrep(r) : ''
})
</script>
<!-- ═══ 读取浏览族 scoped 样式(拆自 ToolResultBody.vue,零视觉变更;路径条族走 path-bar.css 共享,
本组件只持文件预览/目录树/grep 命中行/展开按钮) ═══ -->
<style scoped src="./path-bar.css"></style>
<style scoped>
/* -- File Content (read_file) -- */
.ai-tool-file-content {
border-top: 0.5px solid var(--df-border);
}
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
.ai-tool-grep-result {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-grep-matches {
padding: 4px 0;
max-height: 260px;
overflow-y: auto;
}
.ai-tool-grep-match {
padding: 3px 12px;
border-bottom: 0.5px solid var(--df-hover);
}
.ai-tool-grep-match:last-child {
border-bottom: none;
}
/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
.ai-tool-grep-match-main {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-grep-file {
color: var(--df-accent);
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
padding-top: 1px;
}
.ai-tool-grep-line {
color: var(--df-text-dim);
flex-shrink: 0;
min-width: 2em;
text-align: right;
font-size: 10px;
user-select: none;
padding-top: 1px;
}
.ai-tool-grep-content {
flex: 1;
min-width: 0;
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-text);
white-space: pre-wrap;
word-break: break-all;
background: rgba(61,219,160,0.05);
padding: 2px 6px;
border-radius: var(--df-radius-xs);
}
.ai-tool-grep-context {
margin: 2px 0 0;
padding: 0;
max-height: 120px;
overflow: auto;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
background: var(--df-hover);
border-radius: var(--df-radius-xs);
}
.ai-tool-grep-context code {
display: block;
padding: 4px 6px;
white-space: pre;
}
.ai-tool-expand-btn {
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: var(--df-hover);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.ai-tool-expand-btn:hover {
color: var(--df-text-secondary);
background: rgba(255,255,255,0.08);
border-color: var(--df-border-strong);
}
.ai-tool-file-pre {
margin: 0;
padding: 10px 12px;
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
font-family: var(--df-font-mono);
font-size: 11px;
line-height: 1.6;
color: var(--df-text-secondary);
overflow-x: auto;
tab-size: 4;
}
.ai-tool-file-pre--collapsed {
max-height: 180px;
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
overflow-y: auto;
position: relative;
}
.ai-tool-file-pre--collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
pointer-events: none;
}
/* -- Directory List (list_directory / search_files) -- */
.ai-tool-dir-list {
border-top: 0.5px solid var(--df-border);
}
.ai-tool-dir-entries {
padding: 4px 0;
max-height: 220px;
overflow-y: auto;
}
.ai-tool-dir-entry {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 12px;
font-size: 11px;
transition: background 0.1s;
}
.ai-tool-dir-entry:hover {
background: var(--df-hover);
}
.ai-tool-dir-entry--directory .ai-tool-dir-name {
font-weight: 500;
color: var(--df-text);
}
.ai-tool-dir-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.ai-tool-dir-icon--folder { color: var(--df-warning); }
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
.ai-tool-dir-name {
font-family: var(--df-font-mono);
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.ai-tool-dir-size {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
opacity: 0.7;
}
</style>
@@ -0,0 +1,448 @@
<template>
<!-- ═══ 工具结果·写入执行族(拆自 ToolResultBody.vue C4a,零行为变更) ═══
分支顺序/条件/文案与原 v-else-if 链一字不变(原链第 5-9 支):
write_file / patch_file / run_command / http_request / fetch_url -->
<!-- write_file 结果(族内首支用 v-if:本组件根层是新链头,父级仅在 writeRunBranch 命中时挂载,各支按 tc.name 互斥,与原链渲染语义等价) -->
<div v-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="ai-tool-write-path" :data-tip="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div>
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 df-diff-pre/--add/--del/--ctx 样式)
后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
<div class="ai-tool-file-bar">
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :data-tip="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
</div>
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
</div>
</div>
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
<div
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
class="ai-tool-cmd-result"
>
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
<span class="ai-tool-cmd-prompt">$</span>
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
<button
v-if="cmdOutput"
class="ai-tool-cmd-toggle"
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="cmdOutputExpanded = !cmdOutputExpanded"
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
</div>
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 ) -->
<div
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
class="ai-tool-http-result"
>
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
<button
v-if="parsed?.body"
class="ai-tool-cmd-toggle"
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="httpBodyExpanded = !httpBodyExpanded"
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
</div>
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
</div>
<!-- fetch_url 结果:URL + title + render_mode 徽标 + 可折叠 markdown
render_mode='static_fallback'(obscura 未装/失败)→ 显 obscura 未装提示 + "AI 帮装"按钮
(按钮 emit install-obscura → MessageList 调 store.sendMessage 触发 AI 装流式) -->
<div
v-else-if="tc.name === 'fetch_url' && tc.result && tc.status === 'completed' && parsed"
class="ai-tool-fetch-result"
>
<div class="ai-tool-fetch-line">
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
: parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback')
: $t('aiTool.fetchUrlStatic') }}
</span>
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
<span v-if="typeof parsed?.length === 'number' && parsed?.length > 0" class="ai-tool-file-meta">{{ parsed?.length }} {{ $t('aiTool.charsUnit') }}</span>
<button
v-if="parsed?.markdown"
class="ai-tool-cmd-toggle"
:title="fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
@click="fetchMdExpanded = !fetchMdExpanded"
>{{ fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
</div>
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ $t('aiTool.installObscura') }}
</button>
</div>
<pre v-if="fetchMdExpanded && parsed?.markdown" class="ai-tool-cmd-output"><code>{{ parsed.markdown }}</code></pre>
</div>
</template>
<script setup lang="ts">
/**
* 工具结果·写入执行族 — 拆自 ToolResultBody.vue(C4a 大文件拆分,零行为变更)。
* 职责:write_file / patch_file / run_command / http_request / fetch_url 五支结果渲染(原链第 5-9 支),
* 自持项级状态:失败判定(isFailed)、diff 行拆解与展开、cmd/http/fetch 折叠态、obscura 未装检测。
* 路径条样式经 <style scoped src="./path-bar.css"> 共享(patch_file 路径条 / fetch 字符数 meta)。
*/
import { computed, ref, watch } from 'vue'
import {
isToolFailure,
parseResult,
formatBytes,
argString,
formatSizeDiff,
combineOutputs,
shortPath,
fileIcon,
} from '@/composables/ai/useToolCard'
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
import type { AiToolCallInfo } from '@/api/types'
import { copyResultPath, resultIsFailed } from './shared'
const props = defineProps<{
/** 单条工具调用数据(结果展示只读消费) */
tc: AiToolCallInfo
}>()
const emit = defineEmits<{
/**
* fetch_url static_fallback(obscura 未装)时"AI 帮装"按钮 → 透传父级 → MessageList
* 调 store.sendMessage 触发 AI 装流式安装。仅 fetch_url 工具会发此事件。
*/
'install-obscura': []
}>()
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
const cmdOutputExpanded = ref(false)
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
const httpBodyExpanded = ref(false)
// fetch_url markdown 折叠态:默认折叠(可能很长,头部已有 title + 截断字符数摘要)
const fetchMdExpanded = ref(false)
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
const parsed = computed(() => parseResult(props.tc.result))
/** 是否工具失败(completed 语义)。复用 parsed 不重 parse;判定逻辑本体在 shared.resultIsFailed */
const isFailed = computed(() => resultIsFailed(props.tc, parsed.value))
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
const cmdOutput = computed(() => {
const r = parsed.value
if (!r) return ''
return combineOutputs(r.stdout, r.stderr)
})
/**
* patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
* 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
*/
const DIFF_PREVIEW_LINES = 120
const diffExpanded = ref(false)
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
/**
* fetch_url obscura 未装检测(render=true 但 obscura 未装):
* 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。
* 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。
*/
const obscuraMissing = computed(() => {
if (props.tc.name !== 'fetch_url') return false
const r = parsed.value
if (!r) return false
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
return mode === 'obscura_not_installed'
})
/**
* fetch_url "AI 帮装 obscura" → emit 透传到 MessageList,调 store.sendMessage
* 触发 AI run_command 装流式。安装完成后用户重试即可用 obscura 渲染。
* 提示语足够具体(给出两种装法),AI 拿到能直接 run_command 不需再问。
*/
function onInstallObscura() {
emit('install-obscura')
}
// status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
watch(() => props.tc.status, (s) => {
if (s === 'completed') {
cmdOutputExpanded.value = isToolFailure(props.tc)
httpBodyExpanded.value = isToolFailure(props.tc)
}
}, { immediate: true })
</script>
<!-- ═══ 写入执行族 scoped 样式(拆自 ToolResultBody.vue,零视觉变更;路径条族走 path-bar.css 共享,
本组件持 write/patch 结果、cmd/http/fetch 单行折叠族、diff 行配色与展开按钮) ═══ -->
<style scoped src="./path-bar.css"></style>
<style scoped>
/* -- Write File Result -- */
.ai-tool-write-result {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 0.5px solid var(--df-border);
background: rgba(61,219,160,0.04);
}
.ai-tool-write-path {
font-family: var(--df-font-mono);
font-size: 11px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-write-meta {
font-size: 10px;
color: var(--df-success);
flex-shrink: 0;
}
/* -- patch_file diff 预览(复用审批 diff 同款 token) -- */
.ai-tool-approval-reason {
padding: 4px 10px 6px;
margin-bottom: 6px;
font-size: 11px;
color: var(--df-warning);
background: var(--df-warning-bg);
border-radius: var(--df-radius-sm, 4px);
}
.ai-tool-approval-diff {
margin: 0 10px 8px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
overflow: hidden;
}
/* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波
双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
.df-diff-line--ctx { color: var(--df-text-dim); }
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
.ai-tool-cmd-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-cmd-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04);
}
.ai-tool-cmd-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-cmd-prompt {
flex-shrink: 0;
font-weight: 600;
color: var(--df-success);
}
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
color: var(--df-danger);
}
.ai-tool-cmd-text {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text);
}
.ai-tool-cmd-toggle {
flex-shrink: 0;
padding: 2px 4px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
cursor: pointer;
}
.ai-tool-cmd-toggle:hover {
color: var(--df-text);
background: var(--df-hover);
}
.ai-tool-cmd-output {
margin: 0;
padding: 8px 10px;
max-height: 160px;
overflow-y: auto;
background: rgba(0,0,0,0.18);
color: var(--df-text-dim);
white-space: pre-wrap;
word-break: break-all;
border-top: 0.5px solid var(--df-border);
}
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
.ai-tool-http-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-http-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
}
.ai-tool-http-line--failed {
background: var(--df-danger-bg);
}
.ai-tool-http-method {
flex-shrink: 0;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--df-radius-sm);
background: rgba(255,255,255,0.08);
color: var(--df-text);
font-size: 10px;
}
.ai-tool-http-url {
flex: 1;
min-width: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--df-text-dim);
}
.ai-tool-http-status {
flex-shrink: 0;
font-weight: 600;
color: var(--df-danger);
}
.ai-tool-http-status--ok {
color: var(--df-success);
}
.ai-tool-http-trunc-warn {
padding: 4px 10px;
font-size: 10px;
color: var(--df-warning);
border-top: 0.5px solid var(--df-border);
}
/* -- fetch_url 结果:复用 http-line 视觉 token;render_mode 徽标按 mode 着色 -- */
.ai-tool-fetch-result {
margin: 0;
border-top: 0.5px solid var(--df-border);
font-family: var(--df-font-mono);
font-size: 11px;
}
.ai-tool-fetch-line {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(94,175,240,0.04);
}
.ai-tool-fetch-mode {
/* 复用 .ai-tool-http-method 基样(template 已加该 class),此处仅覆盖 mode 着色 */
font-size: 10px;
}
.ai-tool-fetch-mode--static,
.ai-tool-fetch-mode--obscura {
color: var(--df-success);
}
.ai-tool-fetch-mode--static_fallback {
color: var(--df-warning);
}
.ai-tool-fetch-title {
padding: 4px 10px 2px;
font-family: var(--df-font-sans);
font-size: 11px;
font-weight: 600;
color: var(--df-text);
word-break: break-word;
}
/* obscura 未装引导(static_fallback):warning 色调提示 + AI 帮装按钮 */
.ai-tool-obscura-hint {
margin: 4px 10px 6px;
padding: 6px 8px;
background: var(--df-warning-bg);
border: 0.5px solid var(--df-warning);
border-radius: var(--df-radius-sm, 4px);
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-tool-obscura-hint-text {
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-warning);
line-height: 1.5;
word-break: break-word;
}
/* AI 帮装按钮(accent 色调,对齐 batch-approve 视觉) */
.ai-tool-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 11px;
font-weight: 500;
border-radius: var(--df-radius-sm, 4px);
border: none;
background: var(--df-accent);
color: #fff;
cursor: pointer;
font-family: var(--df-font-sans);
transition: filter 0.15s var(--df-ease);
}
.ai-tool-btn:hover { filter: brightness(1.1); }
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
.ai-tool-diff-toggle {
display: block;
margin: 4px auto 0;
background: transparent;
border: 0.5px dashed var(--df-border);
border-radius: 4px;
color: var(--df-text-secondary);
font-size: 11px;
padding: 3px 12px;
cursor: pointer;
}
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); }
</style>
@@ -0,0 +1,70 @@
/* ═══ 工具结果「路径条」共享样式(.ai-tool-file-bar 族) ═══
拆自 ToolResultBody.vue <style scoped> C4a,零视觉变更。
FileBrowseResult / WriteRunResult 两子件经 <style scoped src="./path-bar.css"> 各自引入
(scoped 随宿主组件哈希,仅命中本组件元素,零全局泄漏;类名不改)。 */
.ai-tool-file-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
border-bottom: 0.5px solid var(--df-border);
}
.ai-tool-file-icon {
display: flex;
align-items: center;
color: var(--df-info);
flex-shrink: 0;
}
.ai-tool-file-icon--dir {
color: var(--df-warning);
}
.ai-tool-file-path {
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-accent);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-tool-file-meta {
font-family: var(--df-font-mono);
font-size: 9px;
color: var(--df-text-dim);
flex-shrink: 0;
}
.ai-tool-file-meta--warn {
color: var(--df-warning);
}
/* R6:路径可点击复制(按钮语义,视觉保持原 span 形态;hover 变 accent 提示可点) */
.ai-tool-file-path--copy {
cursor: pointer;
transition: color 0.15s var(--df-ease);
position: relative;
}
.ai-tool-file-path--copy:hover {
color: var(--df-accent);
}
/* R6b(2026-10-24):原生 title tooltip 系统渲染无法定制,暗色主题露白条(用户实证)。
换 CSS tooltip(::after + data-tip),走 token 随主题切换。 */
.ai-tool-file-path--copy:hover::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 30;
padding: 4px 10px;
background: var(--df-bg-card);
color: var(--df-text);
border: 0.5px solid var(--df-border-strong);
border-radius: var(--df-radius-sm);
box-shadow: var(--df-shadow-sm);
font-family: var(--df-font-mono);
font-size: 11px;
white-space: nowrap;
max-width: 70vw;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 工具结果展示子件族共享逻辑(拆自 ToolResultBody.vue C4a,原样搬出零行为变更)。
* - copyResultPath:FileBrowseResult / WriteRunResult 两子件共用(路径条点击复制反馈);
* - resultIsFailed:主文件(通用兜底分支)与 WriteRunResult 共用(run_command/http_request 失败态)。
*/
import type { AiToolCallInfo } from '@/api/types'
import type { ToolResult } from '@/composables/ai/useToolCard'
// ── R6 结果路径可复制 ──
// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。
// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示),
// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。
let _pathCopiedTimer: ReturnType<typeof setTimeout> | null = null
export async function copyResultPath(e: MouseEvent, path: string): Promise<void> {
if (!path) return
const btn = e.currentTarget as HTMLButtonElement | null
let ok = false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(path)
ok = true
}
} catch { /* 落 execCommand 兜底 */ }
if (!ok) {
const ta = document.createElement('textarea')
ta.value = path
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)
}
if (btn) {
const prev = btn.textContent
btn.textContent = ok ? '✓' : '✕'
if (_pathCopiedTimer) clearTimeout(_pathCopiedTimer)
_pathCopiedTimer = setTimeout(() => { btn.textContent = prev }, 1200)
}
}
/**
* 是否工具失败(completed 语义)。逻辑自 ToolResultBody isFailed computed 原样搬出:
* run_command 按 exit_code 判定;可解析 JSON 且显式 success===false 判失败
* (git status/diff/log 只读工具失败返 {success:false,error} 合法 JSON 对象非 Err,
* 防绿框假成功);不可解析时按原始文本前缀兜底。
*/
export function resultIsFailed(tc: AiToolCallInfo, r: ToolResult | null): boolean {
if (tc.status !== 'completed') return false
if (tc.name === 'run_command') {
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
if (r) return true
}
// 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
if (r && r.success === false) return true
if (!r) {
const raw = typeof tc.result === 'string' ? tc.result : ''
if (!raw) return false
return /^(执行失败|Error:|Failed:)/m.test(raw)
}
return false
}
+19 -1
View File
@@ -49,12 +49,15 @@ const CONV_PAGE_SIZE = 50
const CONV_MAX_PAGES = 40 const CONV_MAX_PAGES = 40
/** 分页游标(模块级单例,主/分离窗各自实例):已拉取条数按组计数 + 是否还有下一页。 /** 分页游标(模块级单例,主/分离窗各自实例):已拉取条数按组计数 + 是否还有下一页。
* reactive 供侧栏「加载更多」按钮直接绑显隐/loading 态。 */ * reactive 供侧栏「加载更多」按钮直接绑显隐/loading 态。
* loadFailed(走查批E):最近一次「加载更多」是否失败——按钮进一次性「加载失败,点击重试」态,
* 下次点击在 loadMoreConversations 入口复位。 */
export const convListPaging = reactive({ export const convListPaging = reactive({
activeLoaded: 0, activeLoaded: 0,
archivedLoaded: 0, archivedLoaded: 0,
hasMore: false, hasMore: false,
loading: false, loading: false,
loadFailed: false,
}) })
/** 拉一页会话摘要。直接 invoke:api 层 listConversations 封装不带 offset 参数, /** 拉一页会话摘要。直接 invoke:api 层 listConversations 封装不带 offset 参数,
@@ -103,6 +106,8 @@ async function loadConversations() {
state.conversations = [...accActive, ...accArchived] state.conversations = [...accActive, ...accArchived]
convListPaging.activeLoaded = aOff convListPaging.activeLoaded = aOff
convListPaging.archivedLoaded = rOff convListPaging.archivedLoaded = rOff
// 走查批E:整表刷新成功即复位「加载更多」失败态(按钮回常态,失败残留不跨刷新存活)
convListPaging.loadFailed = false
// 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。 // 虚拟项保底:活跃会话未落库(懒创建——首条消息前 DB 无记录)时,侧栏显示虚拟占位项。
// 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。 // 后端 ai_conversation_create 仅生成 id 存内存不落库,首条消息发送后 save_conversation 才写库。
// 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。 // 此处 active 会话不在 DB 列表时补一个虚拟项,实现「点新建立即出现在侧栏,不发消息不落库」。
@@ -166,6 +171,7 @@ async function loadConversations() {
async function loadMoreConversations(): Promise<boolean> { async function loadMoreConversations(): Promise<boolean> {
if (convListPaging.loading || !convListPaging.hasMore) return false if (convListPaging.loading || !convListPaging.hasMore) return false
convListPaging.loading = true convListPaging.loading = true
convListPaging.loadFailed = false // 走查批E:重试先复位失败态(按钮回常态)
try { try {
const page = await fetchConvPage(convListPaging.activeLoaded, convListPaging.archivedLoaded) const page = await fetchConvPage(convListPaging.activeLoaded, convListPaging.archivedLoaded)
const act = page.filter(c => !c.archived) const act = page.filter(c => !c.archived)
@@ -179,6 +185,8 @@ async function loadMoreConversations(): Promise<boolean> {
return fresh.length > 0 return fresh.length > 0
} catch (e) { } catch (e) {
console.error('[AI] 加载更多会话失败:', e) console.error('[AI] 加载更多会话失败:', e)
// 走查批E:标记失败,侧栏「加载更多」按钮进一次性重试态(原仅 console 静默)
convListPaging.loadFailed = true
return false return false
} finally { } finally {
convListPaging.loading = false convListPaging.loading = false
@@ -238,6 +246,7 @@ async function newConversation() {
loadMoreCursor.convId = result.id loadMoreCursor.convId = result.id
loadMoreCursor.loading = false loadMoreCursor.loading = false
state.messages = [] state.messages = []
state.messagesPruned = 0 // 换场清空重置淘汰计数(边界条据实)
state.currentText = '' state.currentText = ''
// 新会话挂起为空:写其 per-conv 集合并投影视图(旧会话挂起留在集合里,切回时恢复,不被此清空覆盖) // 新会话挂起为空:写其 per-conv 集合并投影视图(旧会话挂起留在集合里,切回时恢复,不被此清空覆盖)
setConvPendingApprovals(result.id, []) setConvPendingApprovals(result.id, [])
@@ -455,6 +464,8 @@ export async function switchConversation(id: string, force = false, opts?: { noC
// 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留 // 兜底置 activeId:此路径原只持久化不写 state.activeConversationId,旧(不存在)id 残留
// 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由 // 会让后续所有事件被 isCurrent 判为非当前而丢弃或误路由
state.activeConversationId = created.id state.activeConversationId = created.id
// 走查批E:切换提交统一清搜索态(见下方主提交路径注)
state.searchQuery = ''
// 用新对话 id 重走后续逻辑 // 用新对话 id 重走后续逻辑
detail = { id: created.id as ConvId, title: null, messages: '[]' } detail = { id: created.id as ConvId, title: null, messages: '[]' }
// 新建对话无历史,load_more 游标复位 // 新建对话无历史,load_more 游标复位
@@ -463,6 +474,7 @@ export async function switchConversation(id: string, force = false, opts?: { noC
loadMoreCursor.convId = created.id loadMoreCursor.convId = created.id
loadMoreCursor.loading = false loadMoreCursor.loading = false
state.messages = [] state.messages = []
state.messagesPruned = 0 // 换场清空重置淘汰计数(边界条据实)
// 新会话挂起为空(同 newConversation:清视图投影并登记空集合,旧会话挂起保留在集合里) // 新会话挂起为空(同 newConversation:清视图投影并登记空集合,旧会话挂起保留在集合里)
setConvPendingApprovals(created.id, []) setConvPendingApprovals(created.id, [])
notifyConversationChanged() notifyConversationChanged()
@@ -511,8 +523,13 @@ export async function switchConversation(id: string, force = false, opts?: { noC
// (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。 // (DB 主键,prepend 时 v-for key 稳定不重建 DOM),缺失才兜底 `loaded-${i}`。
// targetGen 透传(UX走查 P1-2):在途回载时无结果的 tool_call 标 running。 // targetGen 透传(UX走查 P1-2):在途回载时无结果的 tool_call 标 running。
state.messages = parseConvMessages(rawMsgs, 'loaded', targetGen) state.messages = parseConvMessages(rawMsgs, 'loaded', targetGen)
state.messagesPruned = 0 // 换场整体替换重置淘汰计数(边界条据实)
// parse 成功才置 active(保证 active 与 messages 一致) // parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id state.activeConversationId = id
// 走查批E:切换提交统一清搜索态——顶栏任务切回/删活跃会话回落/openConversation 跳转等
// 所有程序化入口都汇于本函数,原仅侧栏点选与新建两条路手动清;搜索残留会让侧栏停在
// 平铺过滤视图(切走的会话已不可见仍高亮过滤项)。
state.searchQuery = ''
// 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位 // 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中, // 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText, // flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
@@ -661,6 +678,7 @@ async function deleteConversation(id: string) {
if (wasActive) { if (wasActive) {
state.activeConversationId = null state.activeConversationId = null
state.messages = [] state.messages = []
state.messagesPruned = 0 // 活跃会话已删,视图清空重置淘汰计数
} }
}, },
() => aiApi.deleteConversation(id), () => aiApi.deleteConversation(id),
+30 -13
View File
@@ -1,12 +1,5 @@
export default { export default {
aiChat: { aiChat: {
// ── Multi-Agent parallel execution ──
conflictsPending: '{n} conflicts to resolve',
acceptA: 'Accept A',
acceptB: 'Accept B',
mergeBoth: 'Merge Both',
manualResolve: 'Manual',
// ── Conversation sidebar ── // ── Conversation sidebar ──
sidebarTitle: 'Chats', sidebarTitle: 'Chats',
newConversation: 'New chat', newConversation: 'New chat',
@@ -23,6 +16,12 @@ export default {
archivedCount: 'Archived ({n})', archivedCount: 'Archived ({n})',
recoverableCount: 'Recoverable ({n})', recoverableCount: 'Recoverable ({n})',
recoverableHint: 'Task parked safely; click to resume', recoverableHint: 'Task parked safely; click to resume',
// 走查批E: sidebar conversation status dot titles (red/amber/gray; green reuses generating)
statusPendingApproval: 'Pending approval',
statusParked: 'Task parked; recoverable',
statusIdle: 'Idle',
// 走查批E: one-shot retry state of the conversation list "load more" button
loadMoreFailRetry: 'Load failed, click to retry',
// ── Unattended engine badge (TopBar; shown while on, red badge resets the breaker) ── // ── Unattended engine badge (TopBar; shown while on, red badge resets the breaker) ──
unattendedOn: 'Unattended', unattendedOn: 'Unattended',
unattendedExecuting: 'working', unattendedExecuting: 'working',
@@ -65,6 +64,10 @@ export default {
textFileFilter: 'Text/code files', textFileFilter: 'Text/code files',
fileTooLargeTruncated: 'File too large, truncated on insert', fileTooLargeTruncated: 'File too large, truncated on insert',
fileReadFailed: 'Failed to read file, skipped', fileReadFailed: 'Failed to read file, skipped',
// 2026-10-19 drag/paste non-image file saved to disk, inserted as reference line (attachment pattern)
attachmentRefLine: '[Attachment] {name}(readable via read_file): {path}',
attachmentTooLarge: 'File too large (max 20MB), skipped',
attachmentSaveFailed: 'Failed to save attachment: {msg}',
shortcutsTitle: 'Keyboard shortcuts', shortcutsTitle: 'Keyboard shortcuts',
scNewConv: 'New conversation', scNewConv: 'New conversation',
scFocusInput: 'Focus message input', scFocusInput: 'Focus message input',
@@ -174,6 +177,12 @@ export default {
imageReadFailed: 'Failed to read image', imageReadFailed: 'Failed to read image',
imageIgnoredWarn: 'Current model does not support images; they will be ignored on send', imageIgnoredWarn: 'Current model does not support images; they will be ignored on send',
imageOnlyBlocked: 'Current model does not support images; switch to a vision model or add some text', imageOnlyBlocked: 'Current model does not support images; switch to a vision model or add some text',
// Walkthrough: uploading placeholder chip + upload-failure fallback toast
imageUploading: 'Uploading…',
imageUploadFallback: 'Image upload failed; switched to a local reference',
// Message image lightbox (click a message image to enlarge; close via mask click / Esc)
lightboxAlt: 'Message image enlarged',
lightboxClose: 'Close enlarged image',
// ── Confirm dialog ── // ── Confirm dialog ──
confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.', confirmDeleteConversation: 'Delete chat "{title}"? This cannot be undone.',
@@ -195,12 +204,16 @@ export default {
copyAsImage: 'Copy as image', copyAsImage: 'Copy as image',
copiedAsImage: 'Copied as image', copiedAsImage: 'Copied as image',
copyAsImageFailed: 'Failed to copy as image', copyAsImageFailed: 'Failed to copy as image',
// download fallback feedback (file saved to disk, not clipboard copy)
savedAsImageFile: 'Saved as image file',
// ── Message actions bar ── // ── Message actions bar ──
// regenerateUnavailable: Ctrl+R / retry paths still call store.regenerate(); precheck toast key // regenerateUnavailable: Ctrl+R / retry paths still call store.regenerate(); precheck toast key
// (I6 revert removed the UI button only — key still referenced by useAiSend.ts, review 2026-10-05 ②) // (I6 revert removed the UI button only — key still referenced by useAiSend.ts, review 2026-10-05 ②)
regenerateUnavailable: 'No reply to regenerate', regenerateUnavailable: 'No reply to regenerate',
regenerateFailed: 'Regenerate failed: {msg}', regenerateFailed: 'Regenerate failed: {msg}',
// Walkthrough: hint when Ctrl+R/Ctrl+L is swallowed by the streaming guard
generatingBusyHint: 'Still generating; please try again shortly',
// fetch_url "Install obscura via AI" button triggers sendMessage; failure toast (mirrors editMessageFailed) // fetch_url "Install obscura via AI" button triggers sendMessage; failure toast (mirrors editMessageFailed)
sendFailed: 'Send failed: {msg}', sendFailed: 'Send failed: {msg}',
@@ -296,6 +309,9 @@ export default {
viewSummary: 'View context summary', viewSummary: 'View context summary',
// UX walk panel-6 F6-1: inline hint when a history/summary jump misses (target inside a collapsed segment) // UX walk panel-6 F6-1: inline hint when a history/summary jump misses (target inside a collapsed segment)
historyLocateInSegment: 'This message is inside a collapsed segment (archived/compressed). Expand the divider in the message flow first', historyLocateInSegment: 'This message is inside a collapsed segment (archived/compressed). Expand the divider in the message flow first',
// Walkthrough batch: distinguish miss reasons — target pruned by MESSAGE_CAP scroll eviction
historyLocatePruned: 'This message was evicted from the rolling view. Use the banner at the top of the message flow to load full history',
prunedBanner: '{n} earlier messages hidden · Click to load full conversation',
viewContext: 'View AI context', viewContext: 'View AI context',
contextSystemPrompt: 'System Prompt', contextSystemPrompt: 'System Prompt',
contextEnvironment: 'Environment', contextEnvironment: 'Environment',
@@ -314,12 +330,6 @@ export default {
skillInjectedToast: 'Skill "{name}" injected', skillInjectedToast: 'Skill "{name}" injected',
skillNotFoundToast: 'Skill {name} not found', skillNotFoundToast: 'Skill {name} not found',
// ── CIStatus.vue (commit CI checks status card) ──
ciTitle: 'CI Checks',
ciNFailed: '{n} failed',
ciNPending: '{n} pending',
ciNPassed: '{n} passed',
// ── MessageList token detail popover (per-fee breakdown: input/cache hit/output/reasoning) ── // ── MessageList token detail popover (per-fee breakdown: input/cache hit/output/reasoning) ──
tokenDetailTitle: 'Token usage details', tokenDetailTitle: 'Token usage details',
estimated: 'estimated', estimated: 'estimated',
@@ -350,6 +360,8 @@ export default {
// ── UX-P3 chat polish additions ── // ── UX-P3 chat polish additions ──
editEmptyBlocked: 'Edit content is empty, submission cancelled', editEmptyBlocked: 'Edit content is empty, submission cancelled',
skillNoMatchQuery: 'No skill matches "{q}", send blocked', skillNoMatchQuery: 'No skill matches "{q}", send blocked',
// Walkthrough: block send on Enter with no @ mention match (mirrors the skill side)
mentionNoMatchQuery: 'No entity matches "{q}", send blocked',
backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?', backfillOverwriteConfirm: 'The input box has an unsent draft; backfilling will overwrite it. Continue?',
// UX walk panel-2 F2-4: example-card overwrite confirm (aligned with backfill confirm) // UX walk panel-2 F2-4: example-card overwrite confirm (aligned with backfill confirm)
exampleOverwriteConfirm: 'The input box has an unsent draft; the example will overwrite it. Continue?', exampleOverwriteConfirm: 'The input box has an unsent draft; the example will overwrite it. Continue?',
@@ -373,11 +385,16 @@ export default {
outboxStatusCanceled: 'Canceled', outboxStatusCanceled: 'Canceled',
outboxActionFail: 'Action failed, please retry', outboxActionFail: 'Action failed, please retry',
outboxDuePast: 'Scheduled time must be in the future', outboxDuePast: 'Scheduled time must be in the future',
// Walkthrough: invalid reschedule value (NaN) hint; cancel-scheduled-message confirm
outboxDueInvalid: 'Invalid time, please pick again',
outboxCancelConfirm: 'Cancel this scheduled message? This cannot be undone.',
outboxNeedConv: 'No target conversation; create one first', outboxNeedConv: 'No target conversation; create one first',
contextBriefsToggle: 'Expand or collapse project briefs', contextBriefsToggle: 'Expand or collapse project briefs',
contextBriefsTitle: 'Project briefs', contextBriefsTitle: 'Project briefs',
contextBriefsLoading: 'Loading…', contextBriefsLoading: 'Loading…',
contextBriefsEmpty: 'No briefs yet. Conversation takeaways are distilled here automatically when a conversation ends', contextBriefsEmpty: 'No briefs yet. Conversation takeaways are distilled here automatically when a conversation ends',
contextBriefsBringIn: 'Bring into context', contextBriefsBringIn: 'Bring into context',
// draft template pushed into the input box on "Bring into context"
contextBriefsDraft: 'Existing conclusion "{title}" from project "{project}": {summary}',
}, },
} }
+30 -13
View File
@@ -1,12 +1,5 @@
export default { export default {
aiChat: { aiChat: {
// ── 多 Agent 并行执行 ──
conflictsPending: '{n} 处冲突待处理',
acceptA: '接受 A',
acceptB: '接受 B',
mergeBoth: '合并两者',
manualResolve: '手动处理',
// ── 对话侧栏 ── // ── 对话侧栏 ──
sidebarTitle: '对话', sidebarTitle: '对话',
newConversation: '新对话', newConversation: '新对话',
@@ -23,6 +16,12 @@ export default {
archivedCount: '已归档 ({n})', archivedCount: '已归档 ({n})',
recoverableCount: '可恢复 ({n})', recoverableCount: '可恢复 ({n})',
recoverableHint: '任务已安全停靠,点击恢复继续执行', recoverableHint: '任务已安全停靠,点击恢复继续执行',
// 走查批E:侧栏会话状态点 title(红点/琥珀点/灰点;绿点复用 generating)
statusPendingApproval: '有待审批请求',
statusParked: '任务已停靠,可恢复续跑',
statusIdle: '空闲',
// 走查批E:会话列表「加载更多」失败一次性重试态
loadMoreFailRetry: '加载失败,点击重试',
// ── 挂机引擎态徽章(TopBar;开启时显示,熔断红徽点击复位) ── // ── 挂机引擎态徽章(TopBar;开启时显示,熔断红徽点击复位) ──
unattendedOn: '挂机中', unattendedOn: '挂机中',
unattendedExecuting: '执行中', unattendedExecuting: '执行中',
@@ -65,6 +64,10 @@ export default {
textFileFilter: '文本/代码文件', textFileFilter: '文本/代码文件',
fileTooLargeTruncated: '文件过大,已截断插入', fileTooLargeTruncated: '文件过大,已截断插入',
fileReadFailed: '文件读取失败,已跳过', fileReadFailed: '文件读取失败,已跳过',
// 2026-10-19 拖拽/粘贴非图片文件落盘引用行(附件范式,后端 ai_save_dropped_file 落 attachments/)
attachmentRefLine: '[附件] {name}(read_file 可读): {path}',
attachmentTooLarge: '文件过大(上限 20MB),已跳过',
attachmentSaveFailed: '附件落盘失败:{msg}',
shortcutsTitle: '键盘快捷键', shortcutsTitle: '键盘快捷键',
scNewConv: '新建对话', scNewConv: '新建对话',
scFocusInput: '聚焦输入框', scFocusInput: '聚焦输入框',
@@ -175,6 +178,12 @@ export default {
imageReadFailed: '图片读取失败', imageReadFailed: '图片读取失败',
imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略', imageIgnoredWarn: '当前模型不支持图片,发送时图片将被忽略',
imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明', imageOnlyBlocked: '当前模型不支持图片,请改用视觉模型或补充文字说明',
// 走查:上传中占位 chip + 上传失败回退本地引用的轻提示
imageUploading: '上传中…',
imageUploadFallback: '图片上传失败,已改用本地引用',
// 消息图片 lightbox(点击消息图片看大图;点遮罩/Esc 关闭)
lightboxAlt: '消息图片大图',
lightboxClose: '关闭大图',
// ── 确认弹层 ── // ── 确认弹层 ──
confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。', confirmDeleteConversation: '确定删除对话「{title}」?该操作不可恢复。',
@@ -196,12 +205,16 @@ export default {
copyAsImage: '复制为图片', copyAsImage: '复制为图片',
copiedAsImage: '已复制为图片', copiedAsImage: '已复制为图片',
copyAsImageFailed: '复制为图片失败', copyAsImageFailed: '复制为图片失败',
// 降级下载路径的如实反馈(文件已落盘,非剪贴板复制)
savedAsImageFile: '已保存为图片文件',
// ── 消息操作栏── // ── 消息操作栏──
// regenerateUnavailable:Ctrl+R / 重试链仍走 store.regenerate(),前置校验失败 toast 用 // regenerateUnavailable:Ctrl+R / 重试链仍走 store.regenerate(),前置校验失败 toast 用
// (I6 回滚只撤了 UI 按钮,此键仍被 useAiSend.ts 引用——review 2026-10-05 ②) // (I6 回滚只撤了 UI 按钮,此键仍被 useAiSend.ts 引用——review 2026-10-05 ②)
regenerateUnavailable: '没有可重新生成的回复', regenerateUnavailable: '没有可重新生成的回复',
regenerateFailed: '重新生成失败:{msg}', regenerateFailed: '重新生成失败:{msg}',
// 走查:生成中按 Ctrl+R/Ctrl+L 被守卫吞键的轻提示
generatingBusyHint: '正在生成中,请稍后再试',
// fetch_url "AI 帮装 obscura" 按钮触发 sendMessage 失败的 toast(对齐 editMessageFailed 模式) // fetch_url "AI 帮装 obscura" 按钮触发 sendMessage 失败的 toast(对齐 editMessageFailed 模式)
sendFailed: '发送失败:{msg}', sendFailed: '发送失败:{msg}',
@@ -298,6 +311,9 @@ export default {
viewSummary: '查看上下文摘要', viewSummary: '查看上下文摘要',
// 走查-信息UX 面六 F6-1:历史/摘要项点击定位未命中(目标在折叠段内未渲染 DOM)的行内提示 // 走查-信息UX 面六 F6-1:历史/摘要项点击定位未命中(目标在折叠段内未渲染 DOM)的行内提示
historyLocateInSegment: '该消息在折叠段(已归档/已压缩)内,请先在消息流中展开对应分隔条', historyLocateInSegment: '该消息在折叠段(已归档/已压缩)内,请先在消息流中展开对应分隔条',
// 走查批:定位 miss 据实区分——目标已被 MESSAGE_CAP 滚动淘汰,引导点列表头部边界条补齐
historyLocatePruned: '该消息已随会话滚动淘汰,点消息流顶部提示可加载完整历史',
prunedBanner: '以上 {n} 条已省略 · 点击加载完整会话',
viewContext: '查看 AI 上下文', viewContext: '查看 AI 上下文',
contextSystemPrompt: '系统提示', contextSystemPrompt: '系统提示',
contextEnvironment: '环境', contextEnvironment: '环境',
@@ -316,12 +332,6 @@ export default {
skillInjectedToast: '已注入技能《{name}》', skillInjectedToast: '已注入技能《{name}》',
skillNotFoundToast: '技能 {name} 未找到', skillNotFoundToast: '技能 {name} 未找到',
// ── CIStatus.vue(代码提交 CI 检查状态卡)──
ciTitle: 'CI 检查',
ciNFailed: '{n} 项失败',
ciNPending: '{n} 项进行中',
ciNPassed: '{n} 项通过',
// ── MessageList token 详情面板(分计费:输入/缓存命中/输出/思考) ── // ── MessageList token 详情面板(分计费:输入/缓存命中/输出/思考) ──
tokenDetailTitle: 'Token 用量详情', tokenDetailTitle: 'Token 用量详情',
estimated: '估算', estimated: '估算',
@@ -352,6 +362,8 @@ export default {
// ── UX-P3 对话域打磨追加 ── // ── UX-P3 对话域打磨追加 ──
editEmptyBlocked: '编辑内容为空,已取消提交', editEmptyBlocked: '编辑内容为空,已取消提交',
skillNoMatchQuery: '未找到技能「{q}」,已阻止发送', skillNoMatchQuery: '未找到技能「{q}」,已阻止发送',
// 走查:@ 补全无匹配 Enter 阻断发送(对齐技能侧范式)
mentionNoMatchQuery: '未找到匹配实体「{q}」,已阻止发送',
backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?', backfillOverwriteConfirm: '输入框已有未发送草稿,回填将覆盖,确定继续?',
// 走查-信息UX 面二 F2-4:示例问题卡覆盖草稿二次确认(对齐回填确认语义) // 走查-信息UX 面二 F2-4:示例问题卡覆盖草稿二次确认(对齐回填确认语义)
exampleOverwriteConfirm: '输入框已有未发送草稿,点击示例将覆盖,确定继续?', exampleOverwriteConfirm: '输入框已有未发送草稿,点击示例将覆盖,确定继续?',
@@ -375,11 +387,16 @@ export default {
outboxStatusCanceled: '已取消', outboxStatusCanceled: '已取消',
outboxActionFail: '操作失败,请重试', outboxActionFail: '操作失败,请重试',
outboxDuePast: '定时时间必须晚于当前时刻', outboxDuePast: '定时时间必须晚于当前时刻',
// 走查:改期非法值(NaN)提示;取消定时消息二次确认
outboxDueInvalid: '时间无效,请重新选择',
outboxCancelConfirm: '确定取消该条定时消息?取消后不可恢复。',
outboxNeedConv: '无目标会话,请先创建会话', outboxNeedConv: '无目标会话,请先创建会话',
contextBriefsToggle: '展开或收起项目脉络', contextBriefsToggle: '展开或收起项目脉络',
contextBriefsTitle: '项目脉络', contextBriefsTitle: '项目脉络',
contextBriefsLoading: '加载中…', contextBriefsLoading: '加载中…',
contextBriefsEmpty: '暂无知识沉淀。会话结束时会自动提炼对话要点,沉淀到这里', contextBriefsEmpty: '暂无知识沉淀。会话结束时会自动提炼对话要点,沉淀到这里',
contextBriefsBringIn: '带入上下文', contextBriefsBringIn: '带入上下文',
// 「带入上下文」投递到输入框的草稿模板句
contextBriefsDraft: '参考项目「{project}」的既有结论「{title}」:{summary}',
}, },
} }
+12 -1
View File
@@ -85,12 +85,17 @@ import { useAiContext } from '@/composables/ai/useAiContext'
// reactive 包装后的对象(先声明后赋值,accessor 闭包延迟读 stateRef,避免 TDZ)。 // reactive 包装后的对象(先声明后赋值,accessor 闭包延迟读 stateRef,避免 TDZ)。
const _stateBase: { const _stateBase: {
messages: AiMessage[] messages: AiMessage[]
/** MESSAGE_CAP 滚动淘汰累计条数(边界条/历史定位据实提示;换场清空处重置) */
messagesPruned: number
// F-09 per-conv:streaming/currentText 是 accessor(下方 Object.defineProperty 定义), // F-09 per-conv:streaming/currentText 是 accessor(下方 Object.defineProperty 定义),
// 委派 aiShared.convStreamStates 按 activeConversationId 索引读写。streaming/currentText // 委派 aiShared.convStreamStates 按 activeConversationId 索引读写。streaming/currentText
// 不在字面量显式赋值(由 accessor 提供),故 _stateBase 类型不含此两键; // 不在字面量显式赋值(由 accessor 提供),故 _stateBase 类型不含此两键;
// 通过下方 StateWithStream 接口扩展导出 state 的完整类型(消费方编译可见)。 // 通过下方 StateWithStream 接口扩展导出 state 的完整类型(消费方编译可见)。
pendingApprovals: AiToolCallInfo[] pendingApprovals: AiToolCallInfo[]
providers: AiProviderConfig[] providers: AiProviderConfig[]
// providers 列表已定形(loadProviders 成功/失败都置 true)。MessageList 空态以此为信号,
// 避免异步加载完成前把「列表为空」误判为「未配置 AI 提供商」而闪现引导文案
providersLoaded: boolean
activeProvider: string | null activeProvider: string | null
panelOpen: boolean panelOpen: boolean
maximized: boolean maximized: boolean
@@ -110,11 +115,15 @@ const _stateBase: {
convTokenTotal: { prompt: number; completion: number; total: number } | null convTokenTotal: { prompt: number; completion: number; total: number } | null
} = { } = {
messages: [], messages: [],
// MESSAGE_CAP 滚动淘汰累计条数(走查批:淘汰静默无痕,边界条与历史定位提示据实区分)。
// 切换/新建/删除会话等换场清空处重置为 0,prepend 式 loadMoreHistory 不动它。
messagesPruned: 0,
pendingApprovals: [], pendingApprovals: [],
// 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates // 批4 双轨收口:generatingConvs(Set,bool 轨)已退役。会话生成态真相归 convStates
// (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。 // (reactive Map,enum 轨),定义于 composables/ai/aiShared.ts(下沉破环)。
// 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。 // 消费方经 isGenerating(convId) helper 或直接 getConvState(convId) 读,不再读 state.xxx。
providers: [], providers: [],
providersLoaded: false,
activeProvider: null, activeProvider: null,
panelOpen: true, panelOpen: true,
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽) // 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
@@ -225,7 +234,9 @@ watch(
newLen - oldLen <= 2 && // 最多连续 push 2 条触发滚动(useAiSend user+ai=2) newLen - oldLen <= 2 && // 最多连续 push 2 条触发滚动(useAiSend user+ai=2)
newLen > MESSAGE_CAP newLen > MESSAGE_CAP
) { ) {
state.messages.splice(0, newLen - MESSAGE_CAP) const pruned = newLen - MESSAGE_CAP
state.messages.splice(0, pruned)
state.messagesPruned += pruned
} }
}, },
) )