优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)
- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分 - 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls) - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转 - 多条/轮提取,system_prompt 仅注入 active 目标 - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见) - 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护 - 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见 - 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite - 分离窗口默认置顶+置顶状态持久化(刷新后保持) - header 菜单聚合:清空/压缩上下文收入 ... popout - LLM 文字重叠防御:delta 重复检测+丢弃 - 置顶图标:星→大头针 pushpin - 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
This commit is contained in:
+21
@@ -33,6 +33,10 @@
|
||||
<transition name="toast">
|
||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||
</transition>
|
||||
|
||||
<!-- 审批待办浮层(屏幕右上角,窗口置顶时浮于所有桌面窗口上面)。
|
||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer 等其他分离窗口不挂)。 -->
|
||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -44,6 +48,7 @@ import { useProjectStore } from './stores/project'
|
||||
import { useAppSettingsStore } from './stores/appSettings'
|
||||
import i18n from './i18n'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { aiApi } from '@/api'
|
||||
@@ -53,6 +58,22 @@ const isDetached = computed(() => route.path === '/ai-detached' || route.path ==
|
||||
const aiStore = useAiStore()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||
|
||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||
async function activateAiPanel() {
|
||||
if (isDetached.value) {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().setFocus()
|
||||
} catch { /* 忽略 */ }
|
||||
} else {
|
||||
if (!aiStore.state.panelOpen) aiStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 全局错误 toast(消费 projectStore.error,所有 action 失败统一反馈)──
|
||||
const projectStore = useProjectStore()
|
||||
const errorMsg = ref('')
|
||||
|
||||
+9
-2
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: string[]
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: GoalEntry[]
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
@@ -600,7 +600,7 @@ export interface AiConversationSummary {
|
||||
title: string | null
|
||||
provider_id: string | null
|
||||
model: string | null
|
||||
pinned_goals?: string[]
|
||||
pinned_goals?: GoalEntry[]
|
||||
archived: boolean
|
||||
pinned?: boolean
|
||||
prompt_tokens?: number | null
|
||||
@@ -609,6 +609,13 @@ export interface AiConversationSummary {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** G1 目标钉扎:单条目标记录(含状态追踪) */
|
||||
export interface GoalEntry {
|
||||
text: string
|
||||
/** "active" | "completed" */
|
||||
status: 'active' | 'completed'
|
||||
}
|
||||
|
||||
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */
|
||||
export type ApprovalSelectType = 'single' | 'multiple'
|
||||
|
||||
|
||||
@@ -510,13 +510,25 @@ onMounted(async () => {
|
||||
console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||
})
|
||||
|
||||
// ── 置顶功能 ──
|
||||
// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)──
|
||||
const alwaysOnTop = ref(false)
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
// 从 SQLite KV 恢复上次置顶状态
|
||||
const savedAlwaysOnTop = appSettingsStore.get<boolean>('df-ai-always-on-top', false)
|
||||
alwaysOnTop.value = savedAlwaysOnTop
|
||||
// 若恢复值为 true(上次是置顶态),同步到 Tauri 窗口
|
||||
if (savedAlwaysOnTop) {
|
||||
// 异步设(不阻塞初始化流程)
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
getCurrentWebviewWindow().setAlwaysOnTop(true).catch(() => {/* 静默 */})
|
||||
}
|
||||
async function toggleAlwaysOnTop() {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = getCurrentWebviewWindow()
|
||||
await win.setAlwaysOnTop(!alwaysOnTop.value)
|
||||
alwaysOnTop.value = !alwaysOnTop.value
|
||||
// 持久化到 SQLite KV(刷新后恢复)
|
||||
appSettingsStore.set('df-ai-always-on-top', alwaysOnTop.value)
|
||||
}
|
||||
|
||||
// 第五批抽离: F-260616-03 max_iterations 暂停态操作卡(maxRoundsActing/showMaxRoundsCard/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<!-- ═══ 审批待办浮层(屏幕右上角,置顶到所有窗口上面)═══
|
||||
设计目的:当 aichat 面板被遮挡/隐藏/最小化,有 pending 审批时,
|
||||
用户能立刻看到通知并一键激活 AI 面板处理。
|
||||
实现:
|
||||
- 监听 store.state.pendingApprovals(由 AiApprovalRequired 事件驱动)
|
||||
- 位置 position:fixed top:16px right:16px,z-index 高于面板
|
||||
- 窗口本身 alwaysOnTop 时浮层自然置顶到所有桌面窗口上面
|
||||
- 点击浮层 → emit 'activate'(App.vue 决定激活主面板/分离窗口)
|
||||
- 用户关闭后 60s 内同会话不再弹(防审批流期间反复打扰),新审批追加会再显 -->
|
||||
<Transition name="approval-overlay">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="approval-overlay"
|
||||
role="alert"
|
||||
@click="onActivate"
|
||||
>
|
||||
<div class="approval-overlay-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
<span v-if="count > 1" class="approval-overlay-badge">{{ count }}</span>
|
||||
</div>
|
||||
<div class="approval-overlay-body">
|
||||
<div class="approval-overlay-title">{{ $t('aiChat.approvalWaitingTitle') }}</div>
|
||||
<div class="approval-overlay-desc">{{ $t('aiChat.approvalWaitingDesc') }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="approval-overlay-close"
|
||||
:title="$t('common.close')"
|
||||
@click.stop="onDismiss"
|
||||
>
|
||||
<svg width="12" height="12" 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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAiStore } from '@/stores/ai'
|
||||
|
||||
const store = useAiStore()
|
||||
const emit = defineEmits<{
|
||||
/** 用户点击浮层主体:激活 AI 面板/分离窗口 */
|
||||
(e: 'activate'): void
|
||||
}>()
|
||||
|
||||
// 待审批数(0 = 无)
|
||||
const count = computed(() => store.state.pendingApprovals.length)
|
||||
|
||||
// 用户主动 dismiss 后 60s 内不再弹(同会话审批流期反复打扰防护)
|
||||
const dismissedAt = ref<number>(0)
|
||||
const DISMISS_COOLDOWN_MS = 60_000
|
||||
|
||||
// 显隐:有 pending + 未在冷却期
|
||||
const visible = computed(() => {
|
||||
if (count.value === 0) return false
|
||||
if (dismissedAt.value && Date.now() - dismissedAt.value < DISMISS_COOLDOWN_MS) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 审批清空时复位 dismiss(下次有新审批会重新弹)
|
||||
watch(count, (c) => {
|
||||
if (c === 0) dismissedAt.value = 0
|
||||
})
|
||||
|
||||
function onActivate() {
|
||||
emit('activate')
|
||||
}
|
||||
|
||||
function onDismiss() {
|
||||
dismissedAt.value = Date.now()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
||||
.approval-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: var(--df-radius-md);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(123, 111, 240, 0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--df-ease);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.approval-overlay:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--df-accent);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
||||
}
|
||||
|
||||
.approval-overlay-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.approval-overlay-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--df-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1.5px solid var(--df-bg-card);
|
||||
}
|
||||
|
||||
.approval-overlay-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-overlay-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.approval-overlay-desc {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.approval-overlay-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
}
|
||||
|
||||
.approval-overlay-close:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* 进出动画:从右侧滑入 */
|
||||
.approval-overlay-enter-active,
|
||||
.approval-overlay-leave-active {
|
||||
transition: all 0.25s var(--df-ease);
|
||||
}
|
||||
|
||||
.approval-overlay-enter-from,
|
||||
.approval-overlay-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
/* 响应式:窄屏占满右侧 */
|
||||
@media (max-width: 480px) {
|
||||
.approval-overlay {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -53,32 +53,48 @@
|
||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||
<svg 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 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响)。
|
||||
空会话/全归档/生成中禁用(无活跃消息时不发起无意义 IPC)。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:disabled="!hasActiveMessages || store.state.streaming"
|
||||
:title="$t('aiChat.clearContext')"
|
||||
@click="emit('clear-context')"
|
||||
>
|
||||
<svg width="13" height="13" 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>
|
||||
</button>
|
||||
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档)。
|
||||
loading(isCompressing)时禁用 + 显示 spinner;空/全归档/生成中禁用。 -->
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:class="{ 'ai-btn-icon--active': store.isCompressing.value }"
|
||||
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
|
||||
:title="store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext')"
|
||||
@click="emit('compress-context')"
|
||||
>
|
||||
<svg v-if="!store.isCompressing.value" width="13" height="13" 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 v-else class="ai-btn-spinner" width="13" height="13" 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>
|
||||
</button>
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删) -->
|
||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
|
||||
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
|
||||
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
|
||||
<div class="ai-more-menu" ref="moreMenuRef">
|
||||
<button
|
||||
class="ai-btn-icon"
|
||||
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
|
||||
:title="$t('aiChat.moreActions')"
|
||||
@click="moreMenuOpen = !moreMenuOpen"
|
||||
>
|
||||
<svg 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 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 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 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>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 嵌入模式:最大化/还原 + 分离 + 关闭 -->
|
||||
<template v-if="!detached">
|
||||
<button class="ai-btn-icon" @click="emit('toggle-maximize')" :title="store.state.maximized ? $t('aiChat.restoreSidebar') : $t('aiChat.maximize')">
|
||||
@@ -98,7 +114,7 @@
|
||||
<svg 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')">
|
||||
<svg 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 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
<svg 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')">
|
||||
<svg 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>
|
||||
@@ -115,11 +131,13 @@
|
||||
<div v-if="goals.length" class="ai-goals-inline">
|
||||
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" :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"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
{{ goals.length }}
|
||||
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
|
||||
</span>
|
||||
<div v-if="goalsExpanded" class="ai-goals-inline-list">
|
||||
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
|
||||
<span class="ai-goal-text">{{ g }}</span>
|
||||
<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)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,6 +270,26 @@ const emit = defineEmits<{
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ══ 更多菜单 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))
|
||||
|
||||
const activeProviderName = computed(() => {
|
||||
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
||||
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
||||
@@ -296,8 +334,8 @@ const currentConvTitle = computed(() => {
|
||||
return conv?.title || ''
|
||||
})
|
||||
|
||||
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
|
||||
const goals = ref<string[]>([])
|
||||
// 🎯 对话目标面板(对话透明化 L1:Goal visibility)
|
||||
const goals = ref<GoalEntry[]>([])
|
||||
const goalsExpanded = ref(false)
|
||||
|
||||
// ── 📋 历史消息(当前会话 user 消息列表) ──
|
||||
@@ -437,10 +475,14 @@ watch(() => store.state.messages.length, () => {
|
||||
function loadGoals(convId: string | null) {
|
||||
if (!convId) { goals.value = []; return }
|
||||
const conv = store.state.conversations.find(c => c.id === convId)
|
||||
goals.value = conv?.pinned_goals ?? []
|
||||
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
|
||||
const raw = conv?.pinned_goals ?? []
|
||||
goals.value = raw.map((g: any) =>
|
||||
typeof g === 'string' ? { text: g, status: 'active' as const } : g
|
||||
)
|
||||
}
|
||||
|
||||
/** 移除第 i 个目标(移除最后一个自动收起) */
|
||||
/** 移除第 i 个目标 */
|
||||
async function removeGoal(i: number) {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
@@ -448,7 +490,8 @@ async function removeGoal(i: number) {
|
||||
next.splice(i, 1)
|
||||
goals.value = next
|
||||
if (next.length === 0) goalsExpanded.value = false
|
||||
await aiApi.updateConversationGoals(convId, next)
|
||||
// API 接受 string[] 或 GoalEntry[]
|
||||
await aiApi.updateConversationGoals(convId, next.map(g => g.text))
|
||||
}
|
||||
|
||||
// 循环切换 Provider(provider bar 点击)
|
||||
|
||||
@@ -65,8 +65,8 @@
|
||||
</div>
|
||||
<!-- AI 面板切换按钮 -->
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||
<span class="nav-link-icon">
|
||||
<svg width="16" height="16" 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 class="nav-link-icon ai-spark-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
|
||||
</span>
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||
@@ -341,6 +341,15 @@ const secondaryNav = [
|
||||
color: var(--df-accent);
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
/* AI 入口用星花图标 + 轻发光,与其他 nav-link 区分 */
|
||||
.ai-spark-icon {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ai-toggle-btn:hover .ai-spark-icon,
|
||||
.ai-toggle-btn--active .ai-spark-icon {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 3px var(--df-accent));
|
||||
}
|
||||
.ai-toggle-shortcut {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<!-- ═══ 设置导入 / 导出(阶段6 UX 重构)═══
|
||||
导出:聚合 appSettings 全 KV(已解析)+ 知识库配置 → JSON 文件下载
|
||||
(默认不含 provider keyring 密钥,scope 标注)
|
||||
导入:input[type=file] 读 JSON → 解析校验 → useConfirm 覆盖确认
|
||||
→ 逐 KV settings.set + knowledgeApi.saveConfig → toast 提示
|
||||
复用 settings.css 全局 .btn/.btn-primary/.btn-ghost/.btn-sm;
|
||||
confirm/toast 由父 Settings.vue 经 props 注入,共用壳层弹层与 toast。 -->
|
||||
<div class="settings-io">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="onExport">
|
||||
⬆ {{ $t('settings.export') }}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="busy" @click="triggerImport">
|
||||
⬇ {{ $t('settings.import') }}
|
||||
</button>
|
||||
<!-- 隐藏的文件选择器(点击「导入」触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="file-input"
|
||||
@change="onFilePicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================
|
||||
// 阶段6 导入 / 导出 — 纯前端实现
|
||||
// ------------------------------------------------------------
|
||||
// 导出:从 appSettings.cache 取已解析的全 KV(类型正确,非后端原始 JSON 字符串),
|
||||
// 叠加 knowledgeApi.getConfig(),组成 {metadata, appSettings, knowledge} JSON。
|
||||
// 用 Blob + a[download] 下载(不依赖 Tauri fs/dialog 权限)。
|
||||
// 导入:<input type=file> + FileReader.readAsText → 解析 → metadata.scope 校验
|
||||
// → confirmDialog 覆盖确认 → appSettings.set 逐 KV + knowledgeApi.saveConfig
|
||||
// → toast 反馈(并发/轮次需重启或下次对话生效)。
|
||||
// 安全:scope 仅含 appSettings + 知识库配置,默认不含 provider keyring 密钥
|
||||
// (密钥在独立 store,本流程不触及)。
|
||||
// ============================================================
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { knowledgeApi } from '@/api/knowledge'
|
||||
import type { KnowledgeConfig } from '@/api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 覆盖确认弹层(复用 Settings.vue 壳层 confirmState) */
|
||||
confirmDialog: (msg: string, dangerLabel?: string) => Promise<boolean>
|
||||
/** toast 反馈通道(复用 Settings.vue 壳层 showToast) */
|
||||
toast: (msg: string, type?: 'error' | 'warning' | 'info') => void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
const busy = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 导出的 JSON 备份载荷结构 */
|
||||
interface SettingsBackup {
|
||||
metadata: {
|
||||
version: number
|
||||
date: string
|
||||
scope: 'appSettings+knowledge'
|
||||
app: string
|
||||
}
|
||||
appSettings: Record<string, unknown>
|
||||
knowledge: KnowledgeConfig | null
|
||||
}
|
||||
|
||||
/** 当前日期戳(用于导出文件名:devflow-settings-YYYYMMDD.json) */
|
||||
function dateStamp(): string {
|
||||
const d = new Date()
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${day}`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导出
|
||||
// ============================================================
|
||||
async function onExport() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
// appSettings.cache 为已解析(反序列化)值的响应式缓存 —— 取其快照,
|
||||
// 保证导出的 JSON 值类型与运行时一致(非后端原始 JSON 字符串)
|
||||
const allKv: Record<string, unknown> = { ...appSettings.cache }
|
||||
// 知识库配置独立 IPC 读取(不在 app_settings 表)
|
||||
let knowledge: KnowledgeConfig | null = null
|
||||
try {
|
||||
knowledge = await knowledgeApi.getConfig()
|
||||
} catch (e) {
|
||||
// 知识库读取失败不阻断导出(appSettings 仍可导),仅 console 记录
|
||||
console.error('[设置导出] 读取知识库配置失败:', e)
|
||||
}
|
||||
|
||||
const payload: SettingsBackup = {
|
||||
metadata: {
|
||||
version: 1,
|
||||
date: new Date().toISOString(),
|
||||
scope: 'appSettings+knowledge',
|
||||
app: 'devflow',
|
||||
},
|
||||
appSettings: allKv,
|
||||
knowledge,
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
// Blob + a[download] 下载:webview 内原生支持,无需 Tauri fs 权限
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `devflow-settings-${dateStamp()}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
props.toast(t('settings.exportFail', { msg: errMsg(e) }), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导入
|
||||
// ============================================================
|
||||
function triggerImport() {
|
||||
// 重置 value 以便「选同一文件」也能再次触发 change(否则 onChange 不再回调)
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onFilePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const text = await readFileAsText(file)
|
||||
const parsed = parseJsonLoose(text)
|
||||
if (!parsed || !isSettingsBackup(parsed)) {
|
||||
props.toast(t('settings.importInvalidJson'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 覆盖确认(复用壳层 confirmState 弹层;用户取消则放弃)
|
||||
const ok = await props.confirmDialog(t('settings.importConfirm'), '')
|
||||
if (!ok) return
|
||||
|
||||
// 逐 KV 写回 appSettings(经 store.set 同时更新缓存与 debounced 落库)
|
||||
const kv = parsed.appSettings ?? {}
|
||||
for (const [k, v] of Object.entries(kv)) {
|
||||
await appSettings.set(k, v)
|
||||
}
|
||||
// 知识库配置写回(独立 IPC)
|
||||
if (parsed.knowledge) {
|
||||
await knowledgeApi.saveConfig(parsed.knowledge)
|
||||
}
|
||||
|
||||
props.toast(t('settings.importSuccess'), 'info')
|
||||
} catch (e) {
|
||||
props.toast(t('settings.importFail', { msg: errMsg(e) }), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具
|
||||
// ============================================================
|
||||
|
||||
/** FileReader 读文本(替代 file.text() 以兼容更早 webview) */
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error ?? new Error('read error'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** 宽松 JSON.parse:失败返回 null(调用方据此报「格式不符」) */
|
||||
function parseJsonLoose(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 结构校验:必须含 metadata.scope === 'appSettings+knowledge' 且 version 为数 */
|
||||
function isSettingsBackup(obj: unknown): obj is SettingsBackup {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
const o = obj as Record<string, unknown>
|
||||
const meta = o.metadata as Record<string, unknown> | undefined
|
||||
if (!meta || meta.scope !== 'appSettings+knowledge') return false
|
||||
if (typeof meta.version !== 'number') return false
|
||||
// appSettings 必须为对象(允许空对象);knowledge 允许 null 或对象
|
||||
if (typeof o.appSettings !== 'object' || o.appSettings === null) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 提取错误信息文案(兜底 Error.message 或 String(e)) */
|
||||
function errMsg(e: unknown): string {
|
||||
if (e instanceof Error) return e.message
|
||||
return String(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入导出按钮组:横向排列,复用全局 .btn/.btn-ghost/.btn-sm */
|
||||
.settings-io {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
/* 隐藏文件选择器(仅以编程方式 click 触发) */
|
||||
.file-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -37,11 +37,6 @@
|
||||
<span class="nav-label">{{ $t(cat.labelKey) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 底部导入导出占位(阶段6 实现) -->
|
||||
<div class="nav-footer-slot">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -251,8 +246,6 @@ defineExpose({
|
||||
.nav-icon { font-size: 16px; line-height: 1; }
|
||||
.nav-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.nav-footer-slot { flex-shrink: 0; margin-top: auto; }
|
||||
|
||||
/* ===== 窄屏响应式:nav 折叠为横向 tab(@media 由父 Settings.vue 控制,
|
||||
此处仅保证横向模式下 nav-item 排列正确)。窄屏不显搜索框(横向空间不足,
|
||||
搜索改为后续可扩展) ===== */
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! 模块级私有:
|
||||
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { aiApi } from '@/api'
|
||||
import { state } from '@/stores/ai'
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
@@ -73,10 +74,13 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
// IPC 成功返回 = 后端已处理(执行工具/拒绝/写入白名单 + emit 事件 + try_continue)。
|
||||
// 乐观更新 tc.status,不依赖后端事件来复位 loading(后端事件可能在 IPC 返回前后任意时刻到达,
|
||||
// 且 findToolCall 若因时序找不到 tc 会致 loading 永不复位)。
|
||||
// approve/authorize 通过 → running(后端正在执行工具);deny/reject → rejected(后端已记审计)。
|
||||
// CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为
|
||||
// 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。
|
||||
if (tc) {
|
||||
const isDeny = isPathKind ? (decision === 'deny') : !approved
|
||||
tc.status = isDeny ? 'rejected' : 'running'
|
||||
if (tc.status !== 'completed') {
|
||||
tc.status = isDeny ? 'rejected' : 'running'
|
||||
}
|
||||
if (isDeny) {
|
||||
tc.result = t('ai.aiTool.rejectedHint')
|
||||
}
|
||||
@@ -102,6 +106,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个,
|
||||
* 非目录聚合——path 类每条独立决策,不合并同目录)。
|
||||
@@ -113,9 +118,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||
*/
|
||||
const _batchProcessing = ref(false)
|
||||
async function batchApprove(decision: 'approve' | 'reject') {
|
||||
if (_batchProcessing.value) return // 去重:批量进行中禁重复点击
|
||||
_batchProcessing.value = true
|
||||
// 快照当前待审批列表(遍历中 state.pendingApprovals 会因事件回调而缩短)
|
||||
const items = [...state.pendingApprovals]
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
for (const p of items) {
|
||||
try {
|
||||
if (p.kind === 'path') {
|
||||
@@ -125,11 +135,23 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
||||
// risk 类:approve/reject boolean
|
||||
await approveToolCall(p.id, decision === 'approve')
|
||||
}
|
||||
successCount++
|
||||
} catch {
|
||||
// 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态)
|
||||
failCount++
|
||||
// 继续处理剩余项
|
||||
}
|
||||
}
|
||||
_batchProcessing.value = false
|
||||
// 若有失败项,console.warn 诊断(用户可打开 DevTools 查看具体失败项)
|
||||
if (failCount > 0) {
|
||||
console.warn(`[AI] 批量审批完成: ${successCount} 成功, ${failCount} 失败`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量处理中(供按钮禁用 + spinner 展示) */
|
||||
export function isBatchProcessing(): boolean {
|
||||
return _batchProcessing.value
|
||||
}
|
||||
|
||||
export function useAiApproval() {
|
||||
|
||||
@@ -42,6 +42,9 @@ let _unlistenApprovalClear: (() => void) | null = null
|
||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||
let _startPromise: Promise<void> | null = null
|
||||
|
||||
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
|
||||
let _lastDelta = ''
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
@@ -205,6 +208,7 @@ export function flushCurrentText() {
|
||||
// 自清把 flush 语义收敛为"回填并归零",消除对调用方清空顺序的依赖。各调用方后续的
|
||||
// currentText='' 对已清空值幂等,无副作用。
|
||||
state.currentText = ''
|
||||
_lastDelta = '' // 同步重置 delta 跟踪,避免新轮首个 delta 误命中上一轮末 delta 重复检测
|
||||
}
|
||||
|
||||
/** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
|
||||
@@ -274,9 +278,28 @@ function removePendingDirAuth(id: string): void {
|
||||
/** streaming 域:流式文本累积/新轮/心跳/重试/max 轮暂停 */
|
||||
function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
case 'AiTextDelta': {
|
||||
// BUG-260624-01 / UX-260617-12 消息重叠防御:
|
||||
// 历史多次报「LLM 回复文字重复」(如「好好,,现在现在我对我对」单字双重叠加)。
|
||||
// 已排除的根因:后端 stream_recv.rs emit 增量 chunk.delta(非累积)、
|
||||
// startListener 幂等+_startPromise 并发去重、streamingBlocks 块级 memo。
|
||||
// 最可能残留路径:① 主窗口 AI 面板与 ai-detached 分离窗口同时打开,两 webview 独立 JS 上下文
|
||||
// 各自 listen 全局广播事件 → 各自 currentText += delta(用户感知「双倍」)。
|
||||
// 桌面双窗口场景下难以在单窗口内根治,需后端定向 emit_to(label) 重构。
|
||||
// ② LLM provider(GLM anthropic_compat 等)在特定情况下返重复 delta。
|
||||
// 防御:连续两次 delta 完全相同(且非空)时丢弃第二次 + console.warn,留下诊断证据。
|
||||
// 副作用极低:正常 LLM 流不会连发完全相同 delta(空格/单字符除外,已 len>1 守卫)。
|
||||
if (event.delta && _lastDelta === event.delta && event.delta.length > 1) {
|
||||
// df-ai-trace-delta 开关控制(默认开,生产可关)
|
||||
if (appSettings.get('df-ai-trace-delta', true)) {
|
||||
console.warn('[AiTextDelta] 重复 delta 已丢弃(疑似双窗口 listener 或 provider 异常):', JSON.stringify(event.delta))
|
||||
}
|
||||
return true
|
||||
}
|
||||
_lastDelta = event.delta
|
||||
state.currentText += event.delta
|
||||
return true
|
||||
}
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
|
||||
@@ -82,6 +82,7 @@ async function detachPanel(convId?: string) {
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
@@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
let _isSyncing = false
|
||||
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
|
||||
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
|
||||
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
|
||||
let _syncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const SYNC_DEBOUNCE_MS = 150
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
*
|
||||
* 设计原则(本次优化重点):
|
||||
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
|
||||
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
|
||||
* (往左偏移或缩宽),不动主窗口。
|
||||
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
|
||||
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
|
||||
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
|
||||
*/
|
||||
async function syncToMain() {
|
||||
if (_isSyncing) return
|
||||
_isSyncing = true
|
||||
@@ -276,28 +289,32 @@ async function syncToMain() {
|
||||
const monitor = await currentMonitor()
|
||||
const screenWidth = monitor?.size.width ?? 0
|
||||
|
||||
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
|
||||
if (await mainWin.isMaximized()) {
|
||||
await mainWin.unmaximize()
|
||||
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
|
||||
}
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
const maximized = await mainWin.isMaximized()
|
||||
|
||||
let pos = await mainWin.outerPosition()
|
||||
let size = await mainWin.innerSize()
|
||||
// 计算目标位置(默认贴主窗口右侧 + GAP)
|
||||
let targetX = pos.x + size.width + AI_DOCK_GAP
|
||||
let targetY = pos.y
|
||||
let targetHeight = size.height
|
||||
let targetWidth = AI_DOCK_WIDTH
|
||||
|
||||
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
|
||||
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
|
||||
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
|
||||
if (newMainWidth >= 400) {
|
||||
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
|
||||
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
|
||||
pos = await mainWin.outerPosition()
|
||||
size = await mainWin.innerSize()
|
||||
if (maximized) {
|
||||
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
|
||||
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
|
||||
if (screenWidth > 0) {
|
||||
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
|
||||
}
|
||||
targetHeight = size.height > targetHeight ? targetHeight : size.height
|
||||
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
|
||||
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
|
||||
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
|
||||
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
|
||||
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
|
||||
}
|
||||
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
||||
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
|
||||
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
|
||||
} catch (e) {
|
||||
console.error('[AI] syncToMain 失败:', e)
|
||||
} finally {
|
||||
@@ -305,18 +322,28 @@ async function syncToMain() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
|
||||
function scheduleSync() {
|
||||
if (_syncTimer) clearTimeout(_syncTimer)
|
||||
_syncTimer = setTimeout(() => {
|
||||
_syncTimer = null
|
||||
void syncToMain()
|
||||
}, SYNC_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
|
||||
_unlistenMove = await mainWin.onMoved(() => { scheduleSync() })
|
||||
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
|
||||
}
|
||||
|
||||
/** 停止主窗口跟随 */
|
||||
/** 停止主窗口跟随 + 清防抖计时器 */
|
||||
function stopFollowMain() {
|
||||
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
|
||||
@@ -48,6 +48,10 @@ export default {
|
||||
unpin: 'Unpin',
|
||||
closeWindow: 'Close window',
|
||||
|
||||
// ── Approval overlay (global top-right) ──
|
||||
approvalWaitingTitle: 'Approval required',
|
||||
approvalWaitingDesc: 'Click to review and handle',
|
||||
|
||||
// ── Provider status ──
|
||||
clickToSwitchProvider: 'Click to switch provider',
|
||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||
|
||||
@@ -48,6 +48,10 @@ export default {
|
||||
unpin: '取消置顶',
|
||||
closeWindow: '关闭窗口',
|
||||
|
||||
// ── 审批浮层(全局右上角)──
|
||||
approvalWaitingTitle: '有待审批请求',
|
||||
approvalWaitingDesc: '点击查看并处理',
|
||||
|
||||
// ── Provider 状态 ──
|
||||
clickToSwitchProvider: '点击切换 Provider',
|
||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
v-model="activeCategory"
|
||||
:horizontal="isNarrowScreen"
|
||||
@scroll-target="scrollToSettingItem"
|
||||
>
|
||||
<!-- 底部导入/导出(阶段6):窄屏横向模式不显,避免横向 tab 拥挤 -->
|
||||
<template v-if="!isNarrowScreen" #footer>
|
||||
<SettingsImportExport :confirm-dialog="confirmDialog" :toast="showToast" />
|
||||
</template>
|
||||
</SettingsNav>
|
||||
/>
|
||||
<!-- 导入/导出已移除:实测价值低且占用 tab 底部空间,如需跨设备同步建议直接复制 SQLite 文件 -->
|
||||
|
||||
<div ref="contentRef" class="settings-content">
|
||||
<!-- 外观:theme/language/aiLanguage/showTokenUsage -->
|
||||
@@ -79,7 +75,7 @@ import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||||
import SettingsImportExport from '@/components/settings/SettingsImportExport.vue'
|
||||
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { aiApi } from '@/api'
|
||||
import type { AiProviderConfig } from '@/api/types'
|
||||
|
||||
Reference in New Issue
Block a user