优化: 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:
lxy
2026-07-03 13:07:03 +08:00
parent aa0701a2ea
commit 0dfaeb0f5b
26 changed files with 929 additions and 753 deletions
@@ -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>
-7
View File
@@ -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 排列正确)。窄屏不显搜索框(横向空间不足,
搜索改为后续可扩展) ===== */