feat(ai): add summary strategy with brief and standard detail levels

Support two summary strategies (brief/standard) for AI-generated session
summaries. Users can choose their preferred detail level in Settings > AI >
Session Index. Standard (default) produces richer summaries covering people,
events, and key details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor authored and digua committed 2026-05-27 23:47:27 +08:00
1 parent eaff16d555
commit 6727ff609c
21 files changed
+173 -47

No files matched your search

+3 -2
View File
@@ -25,12 +25,13 @@ export function registerSummaryRoutes(
server.post<{
Params: { id: string }
Body: { chatSessionId: number; locale?: string; forceRegenerate?: boolean }
Body: { chatSessionId: number; locale?: string; forceRegenerate?: boolean; strategy?: 'brief' | 'standard' }
}>('/_web/sessions/:id/summaries/generate', async (request, reply) => {
const { chatSessionId, locale, forceRegenerate } = request.body
const { chatSessionId, locale, forceRegenerate, strategy } = request.body
const result = await summaryService.generateSummary(adapter, request.params.id, chatSessionId, deps, {
locale,
forceRegenerate,
strategy,
})
if ('error' in result && !result.success) {
return reply.code(400).send({ error: result.error })
+3 -2
View File
@@ -83,9 +83,10 @@ export async function generateSessionSummary(
dbSessionId: string,
chatSessionId: number,
locale: string = 'zh-CN',
forceRegenerate: boolean = false
forceRegenerate: boolean = false,
strategy?: 'brief' | 'standard'
): Promise<{ success: boolean; summary?: string; error?: string }> {
return generateCore(buildDeps(dbSessionId), chatSessionId, { locale, forceRegenerate })
return generateCore(buildDeps(dbSessionId), chatSessionId, { locale, forceRegenerate, strategy })
}
export async function generateSessionSummaries(
+11 -2
View File
@@ -881,12 +881,20 @@ export function registerChatHandlers(ctx: IpcContext): void {
*/
ipcMain.handle(
'session:generateSummary',
async (_, dbSessionId: string, chatSessionId: number, locale?: string, forceRegenerate?: boolean) => {
async (
_,
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
) => {
console.log('[IPC] session:generateSummary request received:', {
dbSessionId,
chatSessionId,
locale,
forceRegenerate,
strategy,
})
try {
const { generateSessionSummary } = await import('../ai/summary')
@@ -894,7 +902,8 @@ export function registerChatHandlers(ctx: IpcContext): void {
dbSessionId,
chatSessionId,
locale || 'zh-CN',
forceRegenerate || false
forceRegenerate || false,
strategy
)
console.log('[IPC] session:generateSummary result:', result)
return result
+3 -2
View File
@@ -284,9 +284,10 @@ export const sessionApi = {
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
): Promise<{ success: boolean; summary?: string; error?: string }> => {
return ipcRenderer.invoke('session:generateSummary', dbSessionId, chatSessionId, locale, forceRegenerate)
return ipcRenderer.invoke('session:generateSummary', dbSessionId, chatSessionId, locale, forceRegenerate, strategy)
},
/**
+2 -1
View File
@@ -1125,7 +1125,8 @@ interface SessionApi {
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
) => Promise<{ success: boolean; summary?: string; error?: string }>
/** 批量生成会话摘要 */
generateSummaries: (
+1
View File
@@ -32,6 +32,7 @@ export const localeConfigSchema = z.object({
export const uiConfigSchema = z.object({
default_session_tab: z.enum(['overview', 'ai-chat']).default('overview'),
session_gap_threshold: z.number().int().min(60).max(86400).default(1800),
summary_strategy: z.enum(['brief', 'standard']).default('standard'),
})
export const configSchema = z.object({
+1 -1
View File
@@ -120,7 +120,7 @@ export {
filterValidMessages,
splitIntoSegments,
} from './summary'
export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult } from './summary'
export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, SummaryStrategy } from './summary'
// LLM Config Store
export { LLMConfigStore, MAX_CONFIG_COUNT } from './llm-config-store'
+55 -25
View File
@@ -29,9 +29,12 @@ export interface SummaryDeps {
}
}
export type SummaryStrategy = 'brief' | 'standard'
export interface SummaryOptions {
locale?: string
forceRegenerate?: boolean
strategy?: SummaryStrategy
}
export interface SummaryResult {
@@ -48,11 +51,18 @@ const SEGMENT_THRESHOLD = 8000
// ==================== Pure algorithms ====================
function getSummaryLengthLimit(messageCount: number): number {
if (messageCount <= 10) return 50
if (messageCount <= 30) return 80
if (messageCount <= 100) return 120
return 200
function getSummaryLengthLimit(messageCount: number, strategy: SummaryStrategy = 'standard'): number {
if (strategy === 'brief') {
if (messageCount <= 10) return 50
if (messageCount <= 30) return 80
if (messageCount <= 100) return 120
return 200
}
// standard: richer summaries covering who, what, and key details
if (messageCount <= 10) return 100
if (messageCount <= 30) return 200
if (messageCount <= 100) return 350
return 500
}
const MEANINGFUL_SHORT_ZH = ['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意']
@@ -166,26 +176,44 @@ export function splitIntoSegments(
// ==================== Prompt builders ====================
function buildSummaryPrompt(content: string, lengthLimit: number, locale: string): string {
if (locale.startsWith('zh')) {
return `请用简洁的语言(${lengthLimit}字以内)总结以下对话的主要内容或话题。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}`
function buildSummaryPrompt(content: string, lengthLimit: number, locale: string, strategy: SummaryStrategy): string {
if (strategy === 'brief') {
if (locale.startsWith('zh')) {
return `请用简洁的语言(${lengthLimit}字以内)总结以下对话的主要内容或话题。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}`
}
return `Summarize the following conversation concisely (max ${lengthLimit} characters). Output only the summary, no prefix, explanation, or quotes.\n\n${content}`
}
return `Summarize the following conversation concisely (max ${lengthLimit} characters). Output only the summary, no prefix, explanation, or quotes.\n\n${content}`
if (locale.startsWith('zh')) {
return `请总结以下聊天记录(${lengthLimit}字以内),需包含:谁参与了讨论、讨论了什么事情、关键细节和结论。用2-4句话描述,不要罗列每条消息。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}`
}
return `Summarize the following chat log (max ${lengthLimit} characters). Include: who participated, what was discussed, key details and conclusions. Use 2-4 sentences, don't list individual messages. Output only the summary, no prefix, explanation, or quotes.\n\n${content}`
}
function buildSubSummaryPrompt(content: string, locale: string): string {
function buildSubSummaryPrompt(content: string, locale: string, strategy: SummaryStrategy): string {
const limit = strategy === 'brief' ? 50 : 100
if (locale.startsWith('zh')) {
return `请用一句话(不超过50字)概括以下对话片段的主要内容。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}`
return `请用一到两句话(不超过${limit}字)概括以下对话片段的主要内容,包括谁说了什么。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}`
}
return `Summarize this conversation segment in one sentence (max 50 characters). Output only the summary, no prefix or quotes.\n\n${content}`
return `Summarize this conversation segment in 1-2 sentences (max ${limit} characters), including who said what. Output only the summary, no prefix or quotes.\n\n${content}`
}
function buildMergePrompt(subSummaries: string[], lengthLimit: number, locale: string): string {
function buildMergePrompt(
subSummaries: string[],
lengthLimit: number,
locale: string,
strategy: SummaryStrategy
): string {
const list = subSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')
if (locale.startsWith('zh')) {
return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${list}`
if (strategy === 'brief') {
if (locale.startsWith('zh')) {
return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${list}`
}
return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Output only the summary, no prefix or quotes.\n\n${list}`
}
return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Output only the summary, no prefix or quotes.\n\n${list}`
if (locale.startsWith('zh')) {
return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。保留参与者、事件和关键细节。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${list}`
}
return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Preserve participants, events, and key details. Output only the summary, no prefix or quotes.\n\n${list}`
}
// ==================== Post-processing ====================
@@ -209,7 +237,7 @@ export async function generateSessionSummary(
chatSessionId: number,
options: SummaryOptions = {}
): Promise<SummaryResult> {
const { locale = 'zh-CN', forceRegenerate = false } = options
const { locale = 'zh-CN', forceRegenerate = false, strategy = 'standard' } = options
const log = deps.logger
try {
@@ -232,20 +260,22 @@ export async function generateSessionSummary(
return { success: false, error: deps.t('summary.tooFewValidMessages', { count: MIN_MESSAGE_COUNT }) }
}
const lengthLimit = getSummaryLengthLimit(validMessages.length)
const lengthLimit = getSummaryLengthLimit(validMessages.length, strategy)
const maxTokens = strategy === 'brief' ? 300 : 600
const subMaxTokens = strategy === 'brief' ? 100 : 200
const content = formatMessages(validMessages)
log?.info(
'Summary',
`Generating summary: sessionId=${chatSessionId}, raw=${rawMessages.length}, valid=${validMessages.length}, chars=${content.length}`
`Generating summary: sessionId=${chatSessionId}, strategy=${strategy}, raw=${rawMessages.length}, valid=${validMessages.length}, chars=${content.length}`
)
let summary: string
if (content.length <= SEGMENT_THRESHOLD) {
summary = await deps.llmComplete(
deps.t('summary.systemPromptDirect'),
buildSummaryPrompt(content, lengthLimit, locale),
{ temperature: 0.3, maxTokens: 300 }
buildSummaryPrompt(content, lengthLimit, locale, strategy),
{ temperature: 0.3, maxTokens }
)
summary = summary.trim()
} else {
@@ -257,8 +287,8 @@ export async function generateSessionSummary(
const segContent = formatMessages(segment)
const sub = await deps.llmComplete(
deps.t('summary.systemPromptDirect'),
buildSubSummaryPrompt(segContent, locale),
{ temperature: 0.3, maxTokens: 100 }
buildSubSummaryPrompt(segContent, locale, strategy),
{ temperature: 0.3, maxTokens: subMaxTokens }
)
subSummaries.push(sub.trim())
}
@@ -268,8 +298,8 @@ export async function generateSessionSummary(
} else {
summary = await deps.llmComplete(
deps.t('summary.systemPromptMerge'),
buildMergePrompt(subSummaries, lengthLimit, locale),
{ temperature: 0.3, maxTokens: 300 }
buildMergePrompt(subSummaries, lengthLimit, locale, strategy),
{ temperature: 0.3, maxTokens }
)
summary = summary.trim()
}
+1 -1
View File
@@ -161,7 +161,7 @@ export {
filterValidMessages,
splitIntoSegments,
} from './ai'
export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult } from './ai'
export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, SummaryStrategy } from './ai'
// LLM Config Store
export { LLMConfigStore, MAX_CONFIG_COUNT } from './ai'
+1
View File
@@ -196,4 +196,5 @@ export interface Preferences {
export interface UiConfig {
default_session_tab: 'overview' | 'ai-chat'
session_gap_threshold: number
summary_strategy?: 'brief' | 'standard'
}
@@ -3,6 +3,7 @@ import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useSessionIndexService } from '@/services'
import { getSummaryStrategy } from '@/composables/useUiConfig'
const props = defineProps<{
open: boolean
@@ -271,7 +272,13 @@ async function startGenerate() {
if (shouldStop.value) break
try {
const result = await useSessionIndexService().generateSummary(props.sessionId, session.id, locale.value, false)
const result = await useSessionIndexService().generateSummary(
props.sessionId,
session.id,
locale.value,
false,
getSummaryStrategy()
)
if (result.success) {
// 成功:显示摘要内容
@@ -5,8 +5,14 @@
*/
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import UITabs from '@/components/UI/Tabs.vue'
import { useDataService, useSessionIndexService } from '@/services'
import { getSessionGapThreshold, patchUiConfig } from '@/composables/useUiConfig'
import {
getSessionGapThreshold,
getSummaryStrategy,
patchUiConfig,
type SummaryStrategy,
} from '@/composables/useUiConfig'
const { t } = useI18n()
@@ -22,6 +28,13 @@ interface SessionIndexStatus {
const DEFAULT_GAP_MINUTES = 30 // 默认30分钟
const sessionGapMinutes = ref(DEFAULT_GAP_MINUTES)
// 摘要策略
const summaryStrategy = ref<SummaryStrategy>('standard')
const summaryStrategyItems = computed(() => [
{ label: t('settings.storage.session.summaryBrief'), value: 'brief' },
{ label: t('settings.storage.session.summaryStandard'), value: 'standard' },
])
// 批量生成相关状态
const allSessionsStatus = ref<SessionIndexStatus[]>([])
const isLoadingSessionStatus = ref(false)
@@ -53,6 +66,11 @@ function loadSessionThreshold() {
sessionGapMinutes.value = Math.round(threshold / 60)
}
function onSummaryStrategyChange(val: string | number) {
summaryStrategy.value = val as SummaryStrategy
patchUiConfig({ summary_strategy: val as SummaryStrategy })
}
// 加载所有会话的索引状态
async function loadSessionIndexStatus() {
isLoadingSessionStatus.value = true
@@ -168,6 +186,7 @@ async function batchRegenerateAll() {
// 组件挂载时加载数据
onMounted(() => {
loadSessionThreshold()
summaryStrategy.value = getSummaryStrategy()
loadSessionIndexStatus()
})
</script>
@@ -222,6 +241,26 @@ onMounted(() => {
</div>
</div>
<!-- 摘要策略设置 -->
<div
class="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/50"
>
<div>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('settings.storage.session.summaryStrategy') }}
</span>
<p class="text-xs text-gray-400">
{{ t('settings.storage.session.summaryStrategyHelp') }}
</p>
</div>
<UITabs
:model-value="summaryStrategy"
:items="summaryStrategyItems"
size="xs"
@update:model-value="onSummaryStrategyChange"
/>
</div>
<!-- 会话索引统计 -->
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between">
+9
View File
@@ -69,6 +69,15 @@ function setupConfigWatchers(svc: ReturnType<typeof usePreferencesService>): voi
}
)
watch(
() => getUiConfig().summary_strategy,
(val) => {
svc.saveUiConfig({ summary_strategy: val }).catch((err) => {
console.warn('[PreferencesSync] Failed to save ui config:', err)
})
}
)
watch(
() => settingsStore.locale,
(val) => {
+7
View File
@@ -7,9 +7,12 @@
import { ref } from 'vue'
import type { UiConfig } from '@/services/preferences/types'
export type SummaryStrategy = 'brief' | 'standard'
const _uiConfig = ref<UiConfig>({
default_session_tab: 'overview',
session_gap_threshold: 1800,
summary_strategy: 'standard',
})
export function getUiConfig(): UiConfig {
@@ -27,3 +30,7 @@ export function patchUiConfig(partial: Partial<UiConfig>): void {
export function getSessionGapThreshold(): number {
return _uiConfig.value.session_gap_threshold
}
export function getSummaryStrategy(): SummaryStrategy {
return _uiConfig.value.summary_strategy ?? 'standard'
}
+5 -1
View File
@@ -274,7 +274,11 @@
"notGeneratedCount": "{count} not generated",
"loadingStatus": "Loading...",
"batchGenerate": "Generate Missing",
"batchRegenerate": "Regenerate All"
"batchRegenerate": "Regenerate All",
"summaryStrategy": "Summary Detail Level",
"summaryStrategyHelp": "Controls how detailed AI-generated summaries will be",
"summaryBrief": "Brief",
"summaryStandard": "Standard"
}
},
"aiPrompt": {
+5 -1
View File
@@ -274,7 +274,11 @@
"notGeneratedCount": "未生成 {count} 件",
"loadingStatus": "読み込み中...",
"batchGenerate": "未生成分を作成",
"batchRegenerate": "すべて再生成"
"batchRegenerate": "すべて再生成",
"summaryStrategy": "要約の詳細度",
"summaryStrategyHelp": "AI が要約を生成する際の詳細さを設定します",
"summaryBrief": "簡潔",
"summaryStandard": "標準"
}
},
"aiPrompt": {
+5 -1
View File
@@ -274,7 +274,11 @@
"notGeneratedCount": "未生成 {count} 个",
"loadingStatus": "加载中...",
"batchGenerate": "生成未索引",
"batchRegenerate": "全部重新生成"
"batchRegenerate": "全部重新生成",
"summaryStrategy": "摘要详细程度",
"summaryStrategyHelp": "影响 AI 生成摘要时的详细程度",
"summaryBrief": "简要",
"summaryStandard": "标准"
}
},
"aiPrompt": {
+5 -1
View File
@@ -274,7 +274,11 @@
"notGeneratedCount": "未產生 {count} 個",
"loadingStatus": "載入中...",
"batchGenerate": "產生未索引項目",
"batchRegenerate": "全部重新產生"
"batchRegenerate": "全部重新產生",
"summaryStrategy": "摘要詳細程度",
"summaryStrategyHelp": "影響 AI 生成摘要時的詳細程度",
"summaryBrief": "簡要",
"summaryStandard": "標準"
}
},
"aiPrompt": {
+3 -2
View File
@@ -52,9 +52,10 @@ export class ElectronSessionIndexAdapter implements SessionIndexAdapter {
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
): Promise<SummaryResult> {
return window.sessionApi.generateSummary(dbSessionId, chatSessionId, locale, forceRegenerate)
return window.sessionApi.generateSummary(dbSessionId, chatSessionId, locale, forceRegenerate, strategy)
}
generateSummaries(dbSessionId: string, chatSessionIds: number[], locale?: string): Promise<BatchSummaryResult> {
+3 -2
View File
@@ -100,13 +100,14 @@ export class FetchSessionIndexAdapter implements SessionIndexAdapter {
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
): Promise<SummaryResult> {
try {
const resp = await fetch(`/_web/sessions/${dbSessionId}/summaries/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chatSessionId, locale, forceRegenerate }),
body: JSON.stringify({ chatSessionId, locale, forceRegenerate, strategy }),
})
const result = await resp.json()
if (!resp.ok) {
+2 -1
View File
@@ -52,7 +52,8 @@ export interface SessionIndexAdapter {
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
forceRegenerate?: boolean,
strategy?: 'brief' | 'standard'
): Promise<SummaryResult>
generateSummaries(dbSessionId: string, chatSessionIds: number[], locale?: string): Promise<BatchSummaryResult>