重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示

修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
2026-06-28 01:40:02 +08:00
parent e4ceb0015b
commit 53e1c1da77
19 changed files with 921 additions and 350 deletions

View File

@@ -20,34 +20,12 @@
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div>
</div>
<!-- F-01 阶段6: 模型选择器(自动/指定)
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话
provider model_configs(空池):下拉空 + 强制自动(toggle 禁用)
仅展示 active provider enabled model_configs -->
<div class="ai-model-picker" :title="modelPickerHint">
<div class="ai-model-mode">
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
:disabled="!enabledModels.length"
@click="setAutoMode"
>{{ $t('aiChat.autoMode') }}</button>
<button
type="button"
class="ai-model-mode-btn"
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
:disabled="!enabledModels.length"
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
@click="setSpecifyMode"
>{{ $t('aiChat.specifyMode') }}</button>
</div>
<!-- 模型选择器(对话透明化 L1:折叠为紧凑下拉) -->
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
:disabled="!isSpecifyMode"
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
@@ -114,20 +92,38 @@
</div>
</div>
<!-- 提供商状态 -->
<div
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.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
<!-- 下半栏:provider + goals 并排 -->
<div class="ai-bottom-bar">
<!-- 提供商状态 -->
<div
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.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<!-- 目标入口(扁平化,不抢眼) -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge" @click="goalsExpanded = !goalsExpanded">
<svg width="10" height="10" 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 }}
</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>{{ g }}</span>
<button class="ai-goal-remove" @click="removeGoal(i)">×</button>
</div>
</div>
</div>
</div>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) -->
<Transition name="ai-provider-switch">
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
@@ -144,6 +140,7 @@
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import type { ModelConfig } from '../../api/types'
defineProps<{
@@ -194,9 +191,9 @@ const activeProviderName = computed(() => {
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
})
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ──
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
const activeProviderRecord = computed(() =>
store.state.providers.find(p => p.id === store.state.activeProvider)
|| store.state.providers.find(p => p.is_default)
@@ -206,51 +203,59 @@ const activeProviderRecord = computed(() =>
const enabledModels = computed(() => {
const p = activeProviderRecord.value
if (!p?.model_configs) return []
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
return p.model_configs
.filter(m => m.enabled)
.slice()
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
})
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
const selectedModelId = computed(() => store.modelOverride.value || '')
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
const modelPickerHint = computed(() => {
if (!enabledModels.value.length) return t('aiChat.noModels')
return isSpecifyMode.value
? t('aiChat.specifyModeHint')
: t('aiChat.autoModeHint')
})
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
function modelOptionLabel(m: ModelConfig): string {
return m.label || m.model_id
}
function setAutoMode() {
store.modelOverride.value = null
}
function setSpecifyMode() {
if (!enabledModels.value.length) return
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
const cur = store.modelOverride.value
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
if (!inPool) {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
}
}
function onModelSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
store.modelOverride.value = v || null
}
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
// 切换会话默认选中权重最高 model
watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
// 从最新 switch 返回的 conversation summary 中取 pinned_goals,
// 增删操作经后端 IPC 持久化到 DB 并同步 per_conv 内存。
const goals = ref<string[]>([])
/** 目标列表展开/折叠 */
const goalsExpanded = ref(false)
/** 监听 activeConversationId 变化,加载目标的精简实现:从 store.conversations 查找当前会话的目标。 */
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标(发消息→save_conversation→DB→store 需重读) */
watch(() => store.state.messages.length, () => {
// loadConversations 异步,等 store 刷新后重读
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)
goals.value = conv?.pinned_goals ?? []
}
/** 移除第 i 个目标:删除后持久化全量列表到后端。 */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const next = [...goals.value]
next.splice(i, 1)
goals.value = next
await aiApi.updateConversationGoals(convId, next)
}
// 循环切换 Provider(provider bar 点击)
function cycleProvider() {
const ps = store.state.providers
@@ -331,45 +336,15 @@ onBeforeUnmount(() => {
opacity: 0.85;
}
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
.ai-model-picker {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0;
}
.ai-model-mode {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.ai-model-mode-btn {
border: none;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.ai-model-mode-btn:hover:not(:disabled) {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-model-mode-btn--active {
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-model-mode-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ai-model-select {
min-width: 0;
max-width: 180px;
@@ -383,6 +358,11 @@ onBeforeUnmount(() => {
outline: none;
cursor: pointer;
}
.ai-model-select--compact {
max-width: 130px;
margin-left: auto;
margin-right: 4px;
}
.ai-model-select:disabled {
opacity: 0.6;
cursor: not-allowed;
@@ -432,19 +412,88 @@ onBeforeUnmount(() => {
animation: ai-btn-spin 0.9s linear infinite;
}
/* ═══ Provider Bar ═══ */
.ai-provider-bar {
/* ═══ 🎯 对话目标面板(对话透明化 L1) ═══ */
.ai-goals-panel {
padding: 6px 14px;
border-bottom: 0.5px solid var(--df-border);
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
}
.ai-goals-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
margin-bottom: 4px;
}
.ai-goals-title {
font-size: 11px;
font-weight: 500;
color: var(--df-accent);
}
.ai-goals-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-goal-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 0;
}
.ai-goal-icon {
font-size: 10px;
flex-shrink: 0;
}
.ai-goal-text {
font-size: 11px;
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ai-goal-remove {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.ai-goal-item:hover .ai-goal-remove {
opacity: 1;
}
.ai-goal-remove:hover {
color: var(--df-danger);
}
/* ═══ Provider Bar + Goals(并排) ═══ */
.ai-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px;
border-bottom: 0.5px solid var(--df-border);
min-height: 26px;
}
.ai-provider-bar {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--df-text-secondary);
white-space: nowrap;
}
.ai-provider-bar--switchable {
cursor: pointer;
}
.ai-provider-bar--switchable:hover {
background: var(--df-bg-card);
color: var(--df-text-primary);
}
.provider-dot {
width: 5px;
@@ -460,6 +509,58 @@ onBeforeUnmount(() => {
.ai-provider-empty {
background: var(--df-danger-bg);
}
/* 目标入口(扁平低调) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-goals-inline-badge {
cursor: pointer;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--df-text-dim);
background: transparent;
transition: color 0.15s, background 0.15s;
opacity: 0.6;
}
.ai-goals-inline-badge:hover {
opacity: 1;
background: var(--df-bg-card);
color: var(--df-text-secondary);
}
.ai-goals-inline-list {
position: absolute;
top: 100%; left: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
background: var(--df-bg, #fff);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm, 4px);
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;
justify-content: space-between;
gap: 8px;
padding: 3px 8px;
font-size: 11px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card, #f5f5f5);
}
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);
}
.provider-hint {
font-size: 11px;
color: var(--df-danger);