重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user