优化: 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:
@@ -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 点击)
|
||||
|
||||
Reference in New Issue
Block a user