- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示 - 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾 - 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询 - 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚) - 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
235 lines
7.3 KiB
Vue
235 lines
7.3 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 召回铁律。
|
|
* 草稿模板句走 i18n(原硬编码中文,en 环境不再漏出中文) */
|
|
function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
|
const convId = store.state.activeConversationId
|
|
if (!convId) return
|
|
pushConvDraftAppend(convId, t('aiChat.contextBriefsDraft', { project: g.source_project, title: b.title, summary: b.summary }))
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
|
|
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
|
.ctx-briefs {
|
|
border-bottom: 0.5px solid var(--df-border);
|
|
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(--df-accent);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.ctx-briefs-count {
|
|
background: var(--df-accent-soft);
|
|
color: var(--df-accent);
|
|
border-radius: var(--df-radius-md);
|
|
padding: 0 6px;
|
|
font-size: 11px;
|
|
font-weight: 400;
|
|
}
|
|
.ctx-briefs-body {
|
|
max-height: 220px;
|
|
overflow-y: auto;
|
|
padding: 6px 8px;
|
|
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
|
border-radius: var(--df-radius);
|
|
margin-bottom: 4px;
|
|
}
|
|
.ctx-briefs-empty {
|
|
color: var(--df-text-dim);
|
|
padding: 6px 2px;
|
|
}
|
|
.ctx-briefs-project {
|
|
font-weight: 600;
|
|
color: var(--df-text);
|
|
font-size: 12px;
|
|
padding: 5px 2px 2px;
|
|
}
|
|
.ctx-briefs-focus-dot {
|
|
display: inline-block;
|
|
width: 5px;
|
|
height: 5px;
|
|
margin-right: 4px;
|
|
border-radius: 50%;
|
|
background: var(--df-accent);
|
|
vertical-align: 2px;
|
|
}
|
|
.ctx-briefs-item {
|
|
padding: 4px 2px;
|
|
border-top: 0.5px dashed var(--df-border);
|
|
}
|
|
.ctx-briefs-item-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 6px;
|
|
}
|
|
.ctx-briefs-item-title {
|
|
font-weight: 500;
|
|
color: var(--df-text);
|
|
}
|
|
.ctx-briefs-item-link {
|
|
padding: 0;
|
|
border: none;
|
|
background: none;
|
|
font: inherit;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.ctx-briefs-item-link:hover {
|
|
color: var(--df-accent);
|
|
}
|
|
.ctx-briefs-item-time {
|
|
margin-left: auto;
|
|
flex-shrink: 0;
|
|
font-size: 10px;
|
|
color: var(--df-text-dim);
|
|
font-family: var(--df-font-mono);
|
|
}
|
|
.ctx-briefs-item-summary {
|
|
color: var(--df-text-secondary);
|
|
font-size: 12px;
|
|
margin: 2px 0;
|
|
line-height: 1.4;
|
|
}
|
|
.ctx-briefs-bring {
|
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
|
background: transparent;
|
|
color: var(--df-accent);
|
|
border-radius: var(--df-radius-sm);
|
|
padding: 1px 8px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.ctx-briefs-bring:hover {
|
|
background: var(--df-accent-soft);
|
|
}
|
|
</style>
|