Files
DevFlow/src/components/ai/ContextBriefsCard.vue
T
lxy 9e26d6b19c 优化: 脉络卡归属置顶与源会话跳转
- 当前会话归属项目组置顶,focused 标记高亮
- source_ref 解析出可跳转会话 id,消息级引用反查补全
- 条目相对时间/空态引导/标题点击跳源,失败静默
2026-10-12 18:12:26 +08:00

223 lines
6.7 KiB
Vue

<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
有源会话的条目标题可点跳回原文,空源或跳转失败静默不打断阅读。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
:title="t('aiChat.contextBriefsToggle')"
@click="toggle"
>
<svg
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
><polyline points="9 18 15 12 9 6"/></svg>
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
</button>
<div v-if="expanded" class="ctx-briefs-body">
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">
<span v-if="g.focused" class="ctx-briefs-focus-dot" />{{ g.source_project }}
</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-head">
<button
v-if="b.source_conv"
class="ctx-briefs-item-title ctx-briefs-item-link"
@click="openSource(b)"
>{{ b.title }}</button>
<span v-else class="ctx-briefs-item-title">{{ b.title }}</span>
<span class="ctx-briefs-item-time">{{ formatRelative(b.updated_at) }}</span>
</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
:title="t('aiChat.contextBriefsBringIn')"
@click="bringIn(g, b)"
>{{ t('aiChat.contextBriefsBringIn') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
import { useAiConversations } from '../../composables/ai/useAiConversations'
import { formatRelative } from '../../utils/time'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_conv: string | null
source_project: string | null
reuse_count: number
updated_at: string
}
interface ProjectBriefs {
source_project: string
focused?: boolean
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const { openConversation } = useAiConversations()
const expanded = ref(false)
const loading = ref(false)
const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
/** 拉取脉络,按当前会话标注聚焦分组;会话已切走时丢弃过期响应(防旧 focused 污染新列表) */
async function load() {
const convId = store.state.activeConversationId ?? undefined
loading.value = true
try {
const result = await aiApi.listContextBriefs(undefined, convId)
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = result
} catch {
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = []
} finally {
loading.value = false
}
}
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) await load()
}
/** 切会话重新拉取(focused 跟随当前会话);收起态只清缓存,保持按需加载 */
watch(() => store.state.activeConversationId, () => {
groups.value = []
if (expanded.value) void load()
})
/** 跳回摘要来源会话(source_conv 由后端自 source_ref 解析,消息级引用已反查);
* 源为空或已删除(not_found)一律静默,不加 toast */
async function openSource(b: ContextBrief) {
if (!b.source_conv) return
try {
await openConversation(b.source_conv)
} catch {
// 静默:跳转失败不打断阅读
}
}
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
function bringIn(g: ProjectBriefs, b: ContextBrief) {
const convId = store.state.activeConversationId
if (!convId) return
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
}
</script>
<style scoped>
.ctx-briefs {
border-bottom: 1px solid var(--border, #e0e0e0);
padding: 4px 10px;
font-size: 12px;
}
.ctx-briefs-toggle {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 2px;
border: none;
background: none;
color: var(--text-secondary, #666);
cursor: pointer;
font-size: 12px;
}
.ctx-briefs-count {
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
color: var(--accent, #4080ff);
border-radius: 8px;
padding: 0 6px;
font-size: 11px;
}
.ctx-briefs-body {
max-height: 220px;
overflow-y: auto;
padding-bottom: 4px;
}
.ctx-briefs-empty {
color: var(--text-tertiary, #999);
padding: 6px 2px;
}
.ctx-briefs-project {
font-weight: 600;
color: var(--text-primary, #333);
padding: 5px 2px 2px;
}
.ctx-briefs-focus-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 4px;
border-radius: 50%;
background: var(--accent, #4080ff);
vertical-align: 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 1px dashed var(--border, #eee);
}
.ctx-briefs-item-head {
display: flex;
align-items: baseline;
gap: 6px;
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--text-primary, #333);
}
.ctx-briefs-item-link {
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-briefs-item-link:hover {
color: var(--accent, #4080ff);
}
.ctx-briefs-item-time {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
color: var(--text-tertiary, #999);
}
.ctx-briefs-item-summary {
color: var(--text-secondary, #666);
margin: 2px 0;
line-height: 1.4;
}
.ctx-briefs-bring {
border: 1px solid var(--border, #ddd);
background: none;
color: var(--accent, #4080ff);
border-radius: 4px;
padding: 1px 8px;
font-size: 11px;
cursor: pointer;
}
.ctx-briefs-bring:hover {
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
}
</style>