优化: 聊天体验批与Web规范合规

This commit is contained in:
lxy
2026-10-05 23:41:32 +08:00
parent 9e81182b4f
commit 28e6acbf3f
41 changed files with 1518 additions and 570 deletions
+154 -2
View File
@@ -25,6 +25,8 @@
@close-panel="store.togglePanel()"
@dock="store.dockDetached()"
@toggle-always-on-top="toggleAlwaysOnTop"
@reattach="onReattachToMain"
@show-shortcuts="showShortcuts"
@close-detached="store.closeDetachedWindow()"
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
/>
@@ -46,6 +48,8 @@
<!-- Input -->
<div class="ai-input-area">
<!-- B4: 自动压缩完成提示(一次性 4s 淡出;治「上下文何时被压缩」零感知,不弹 toast 不打断流) -->
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
@@ -131,6 +135,24 @@
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
<Teleport to="body">
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
<div class="ai-shortcuts-head">
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
</div>
<ul class="ai-shortcuts-list">
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
<span class="ai-shortcuts-key">{{ s.key }}</span>
<span class="ai-shortcuts-act">{{ s.label }}</span>
</li>
</ul>
</div>
</div>
</Teleport>
</div>
</template>
@@ -154,6 +176,8 @@ import { useConfirm } from '../composables/useConfirm'
import { useToast } from '../composables/useToast'
import { useProjectStore } from '../stores/project'
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
import { useAiContext } from '../composables/ai/useAiContext'
import { emitReattachToMain, registerMainReattachListener } from '@/composables/ai/useAiWindow'
import type { AiMessage } from '../api/types'
const props = defineProps<{
@@ -164,6 +188,8 @@ const props = defineProps<{
const store = useAiStore()
const { t } = useI18n()
const router = useRouter()
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
const { autoCompressedFlash } = useAiContext()
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
@@ -278,7 +304,9 @@ async function confirmNewConversation() {
await store.newConversation()
} catch (e) {
console.error('[AI] 新建对话失败:', e)
// 用错误气泡反馈用户,不静默吞掉
// #16(2026-10-05 注释纠偏):原注释称「用错误气泡反馈用户」,实际 catch 内仅 console.error,
// UI 无反馈;失败兜底由 store.newConversation 内部 withConvOp toast 承接,此处不再重复提示,
// 保留 console.error 留痕。注释与实现对齐,避免后续维护误导。
}
}
@@ -338,9 +366,19 @@ function onGlobalKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement | null
if (ctrl && !!el?.closest?.('input, textarea, [contenteditable]')) return
// Ctrl+N: 新建对话(Tauri 下 preventDefault 可靠,无浏览器新窗口冲突)
// V10(2026-10-05):撤 streaming 守卫,对齐按钮侧 handleNewConversation 的 F-09 真并发
// 语义——新建不杀旧 loop,旧会话后台继续,无需「生成中禁新建」;原守卫静默吞键(无反馈)
// 与按钮可点自相矛盾。真并发下新旧会话事件按 conversation_id 路由,无串台风险。
if (ctrl && (e.key === 'n' || e.key === 'N')) {
e.preventDefault()
if (!store.state.streaming) store.newConversation()
store.newConversation()
return
}
// I13(2026-10-05):Ctrl+I 聚焦输入框(输入焦点守卫已让路——焦点已在输入控件内不重复抢);
// Ctrl+K 归会话搜索,聚焦输入框选 I(无浏览器默认冲突,IME 安全)。
if (ctrl && (e.key === 'i' || e.key === 'I')) {
e.preventDefault()
chatInputRef.value?.focus()
return
}
// Ctrl+R: 重新生成末条回复(替代浏览器刷新,Tauri 下 preventDefault 可靠)
@@ -386,6 +424,12 @@ function onGlobalKeydown(e: KeyboardEvent) {
answerConfirm(false) // 视为取消,resolve Promise 打断 confirmClearChat/confirmDelete
return
}
// I12:快捷键速查面板开时 Esc 仅关面板(优先级高于主面板/分离窗关闭,防误关整窗)
if (shortcutsVisible.value) {
e.preventDefault()
shortcutsVisible.value = false
return
}
// P1-2 Esc 仲裁:事件源落在内层 Esc 消费者(输入框/侧栏重命名框/队列编辑框等输入控件,
// 或技能/@联想浮层与技能 chip)时,Esc 语义归内层(关浮层/取消编辑/取消输入法组词),
// 不关分离窗——否则输入框按 Esc 关技能浮层的同时窗口也进入关闭流程,一次按键双重效果。
@@ -612,6 +656,9 @@ onMounted(async () => {
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
})
// V11b: 主窗口注册跨窗收回分离窗监听(label 判定在内部,分离窗调用为 no-op);
// reattachPanel 由主窗 store 执行(关全部 ai-detached-* 窗 + 恢复主面板)。
registerMainReattachListener(() => store.reattachPanel())
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
@@ -674,6 +721,30 @@ async function toggleAlwaysOnTop() {
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
}
// ── V11b 收回分离窗 + I12 快捷键速查面板 ──
// V11b(2026-10-05):分离窗 TopBar「收回」按钮 emit 链——分离窗与主窗是独立 JS context,
// 不能直调主窗 store.reattachPanel;走 Tauri 全局事件广播(emitReattachToMain),主窗 AiChat
// 挂载时注册的 registerMainReattachListener(useAiWindow.ts,按 label==='main' 判定)消费执行。
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
const shortcutsVisible = ref(false)
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
const shortcuts = computed(() => [
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
])
function showShortcuts() {
shortcutsVisible.value = true
}
async function onReattachToMain() {
await emitReattachToMain()
}
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ChatMessage.status 渲染语义(types.ts AiMessage.status 字面量联合定义):
// null | undefined | 'active' → 正常消息(现状)
@@ -833,6 +904,21 @@ async function initContextEventListeners(): Promise<void> {
flex-shrink: 0;
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
}
/* B4: 自动压缩完成提示(一次性 4s;视觉克制度对齐 agentic 进度条,弱存在感) */
.ai-autocompressed-hint {
margin-bottom: 6px;
padding: 3px 8px;
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
border-radius: var(--df-radius);
font-size: 11px;
color: var(--df-text-dim);
user-select: none;
animation: ai-autocompressed-fade 4s ease-in-out forwards;
}
@keyframes ai-autocompressed-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0.35; }
}
.ai-agentic-progress-text {
font-family: var(--df-font-mono);
white-space: nowrap;
@@ -930,4 +1016,70 @@ async function initContextEventListeners(): Promise<void> {
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
.ai-shortcuts-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.ai-shortcuts-panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 320px;
max-width: calc(100vw - 48px);
padding: 14px 16px;
}
.ai-shortcuts-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.ai-shortcuts-title {
font-size: 13px;
font-weight: 600;
color: var(--df-text);
}
.ai-shortcuts-close {
background: transparent;
border: none;
color: var(--df-text-secondary);
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
}
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
.ai-shortcuts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.ai-shortcuts-key {
font-family: var(--df-mono, monospace);
font-size: 11px;
background: var(--df-bg-secondary);
border: 0.5px solid var(--df-border);
border-radius: 4px;
padding: 2px 8px;
color: var(--df-text);
}
.ai-shortcuts-act { color: var(--df-text-secondary); }
</style>