feat: debug_context 记录完整 LLM 上下文

This commit is contained in:
digua
2026-05-02 16:52:09 +08:00
committed by digua
parent df0b2a36d2
commit 99b4d3e275
13 changed files with 167 additions and 71 deletions
+18 -3
View File
@@ -6,7 +6,7 @@
import { getDefaultAssistantConfig, buildPiModel } from '../llm'
import { getAllTools, createActivateSkillTool } from '../tools'
import type { ToolContext } from '../tools/types'
import { getHistoryForAgent } from '../conversations'
import { getHistoryForAgent, setPendingDebugContext } from '../conversations'
import { aiLogger, isDebugMode } from '../logger'
import { t as i18nT } from '../../i18n'
import { Agent as PiAgentCore } from '@mariozechner/pi-agent-core'
@@ -23,7 +23,7 @@ import { buildSystemPrompt } from './prompt-builder'
import { extractThinkingContent, stripToolCallTags } from './content-parser'
import { AgentEventHandler } from './event-handler'
type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'system'; content: string }
type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: string }
// Re-export types for external consumers
export type { AgentConfig, AgentStreamChunk, AgentResult, TokenUsage, AgentRuntimeStatus, SkillContext } from './types'
@@ -194,7 +194,22 @@ export class Agent {
try {
if (isDebugMode()) {
aiLogger.debug('Agent', `[DEBUG] System prompt`, systemPrompt)
// 捕获发给 LLM 的完整上下文(system prompt + history + user message),写入 DB
if (this.context.conversationId) {
try {
const debugMessages = [
{ role: 'system', content: systemPrompt },
...historyMessages.map((m) => ({
role: m.role === 'summary' ? 'assistant' : m.role,
content: m.content,
})),
{ role: 'user', content: userMessage },
]
setPendingDebugContext(this.context.conversationId, JSON.stringify(debugMessages, null, 2))
} catch {
// 静默失败,不影响主流程
}
}
}
await coreAgent.prompt(userMessage)
+15 -29
View File
@@ -6,7 +6,7 @@
* 1. 计算当前上下文总 token → 未超阈值则跳过
* 2. 确定缓冲区:最近 bufferSizePercent% context window 的消息原文
* 3. 缓冲区之前的消息(含旧 system 摘要)→ LLM 压缩为新摘要
* 4. 写入 ai_message(role='system'),替换旧摘要
* 4. 写入 ai_message(role='summary'),替换旧摘要
* 5. Thrashing 检查
*/
@@ -17,6 +17,7 @@ import {
getAllUserAssistantMessages,
addSummaryMessage,
getMessageCountAfterSummary,
setDebugContext,
type ContentBlock,
type AIMessageRole,
} from '../conversations'
@@ -179,30 +180,6 @@ export async function checkAndCompress(
inputTokensEstimate: countTokens(compressInput),
})
// DEBUG 模式下输出原始消息列表和完整压缩输入
if (isDebugMode()) {
aiLogger.debug('Compression', 'Messages to compress (raw)', {
messages: messagesToCompress.map((m, i) => ({
index: i,
role: m.role,
contentLength: m.content.length,
contentPreview: m.content.slice(0, 200),
})),
})
aiLogger.debug('Compression', 'Buffer messages (kept as-is)', {
messages: bufferMessages.map((m, i) => ({
index: i,
role: m.role,
contentLength: m.content.length,
contentPreview: m.content.slice(0, 200),
})),
})
aiLogger.debug('Compression', 'Full compression input sent to LLM', compressInput)
if (summary) {
aiLogger.debug('Compression', 'Previous summary content', summary.content)
}
}
// 使用默认助手模型压缩,失败则强制截断
let summaryText: string | null = null
@@ -219,9 +196,6 @@ export async function checkAndCompress(
outputLength: summaryText.length,
outputTokensEstimate: countTokens(summaryText),
})
if (isDebugMode()) {
aiLogger.debug('Compression', 'Generated summary content', summaryText)
}
}
// 写入 summary:时间戳 = NOW(UI 中显示在触发压缩的位置)
@@ -242,7 +216,19 @@ export async function checkAndCompress(
bufferCount: bufferMessages.length,
})
addSummaryMessage(conversationId, summaryText, summaryMeta)
const summaryMsg = addSummaryMessage(conversationId, summaryText, summaryMeta)
// DEBUG 模式:记录发给压缩 LLM 的完整上下文(与 assistant 的 debug_context 格式一致)
if (isDebugMode()) {
try {
const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT
const fullPrompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', compressInput)
const debugMessages = [{ role: 'user', content: fullPrompt }]
setDebugContext(summaryMsg.id, JSON.stringify(debugMessages, null, 2))
} catch {
// 静默失败,不影响主流程
}
}
// Thrashing 检查:压缩后重新计算 tokensummary + buffer 消息)
const afterTokenCount: Array<{ role: string; content: string }> = [
+62 -22
View File
@@ -13,6 +13,9 @@ const DEFAULT_GENERAL_ID = 'general_cn'
// AI 数据库实例
let AI_DB: Database.Database | null = null
// DEBUG 模式:暂存待写入的 debug contextkey = conversationId
const pendingDebugContextMap = new Map<string, string>()
/**
* 获取 AI 数据库目录
*/
@@ -94,6 +97,12 @@ function migrateAiDatabase(db: Database.Database): void {
console.log('[AI DB Migration] Adding token_usage column to ai_message')
}
// 检查并添加 debug_context 列(仅 DEBUG 模式下写入,存储发给 LLM 的完整上下文)
if (!messageColumns.includes('debug_context')) {
db.exec('ALTER TABLE ai_message ADD COLUMN debug_context TEXT')
console.log('[AI DB Migration] Adding debug_context column to ai_message')
}
// 获取 ai_conversation 表的列信息
const convTableInfo = db.pragma('table_info(ai_conversation)') as Array<{ name: string }>
const convColumns = convTableInfo.map((col) => col.name)
@@ -230,7 +239,7 @@ export type ContentBlock =
/**
* AI 消息类型
*/
export type AIMessageRole = 'user' | 'assistant' | 'system'
export type AIMessageRole = 'user' | 'assistant' | 'summary'
export interface TokenUsageData {
promptTokens: number
@@ -389,10 +398,16 @@ export function addMessage(
const now = Math.floor(Date.now() / 1000)
const id = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
// 检查是否有暂存的 debug context 需要写入(仅 assistant 消息)
const pendingDebug = role === 'assistant' ? pendingDebugContextMap.get(conversationId) : undefined
if (pendingDebug) {
pendingDebugContextMap.delete(conversationId)
}
db.prepare(
`
INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks, token_usage, debug_context)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
).run(
id,
@@ -403,7 +418,8 @@ export function addMessage(
dataKeywords ? JSON.stringify(dataKeywords) : null,
dataMessageCount ?? null,
contentBlocks ? JSON.stringify(contentBlocks) : null,
tokenUsage ? JSON.stringify(tokenUsage) : null
tokenUsage ? JSON.stringify(tokenUsage) : null,
pendingDebug ?? null
)
// 更新对话的 updated_at
@@ -480,6 +496,30 @@ export function deleteMessage(messageId: string): boolean {
return result.changes > 0
}
/**
* 暂存 debug context,等待下一次 addMessage(assistant) 时自动写入
*/
export function setPendingDebugContext(conversationId: string, debugContext: string): void {
pendingDebugContextMap.set(conversationId, debugContext)
}
/**
* 更新消息的 debug_context 字段(仅 DEBUG 模式下调用)
*/
export function setDebugContext(messageId: string, debugContext: string): void {
const db = getAiDb()
db.prepare('UPDATE ai_message SET debug_context = ? WHERE id = ?').run(debugContext, messageId)
}
/**
* 一键清除所有消息的 debug_context 数据
*/
export function clearAllDebugContext(): number {
const db = getAiDb()
const result = db.prepare('UPDATE ai_message SET debug_context = NULL WHERE debug_context IS NOT NULL').run()
return result.changes
}
/**
* 获取对话的累计 token 使用量(聚合所有 assistant 消息的 token_usage
*/
@@ -520,44 +560,44 @@ export function getConversationTokenUsage(conversationId: string): TokenUsageDat
export function getHistoryForAgent(
conversationId: string,
maxMessages?: number
): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
): Array<{ role: 'user' | 'assistant' | 'summary'; content: string }> {
const messages = getMessages(conversationId)
const validMessages = messages.filter(
(m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'system') && m.content?.trim()
(m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && m.content?.trim()
)
// 查找最新的 system (summary) 消息
let systemMsg: AIMessage | undefined
// 查找最新的 summary 消息
let summaryMsg: AIMessage | undefined
for (let i = validMessages.length - 1; i >= 0; i--) {
if (validMessages[i].role === 'system') {
systemMsg = validMessages[i]
if (validMessages[i].role === 'summary') {
summaryMsg = validMessages[i]
break
}
}
let result: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>
let result: Array<{ role: 'user' | 'assistant' | 'summary'; content: string }>
if (systemMsg) {
if (summaryMsg) {
// 从 content_blocks 中解析 summary_meta 获取 buffer 边界
const metaBlock = systemMsg.contentBlocks?.find(
const metaBlock = summaryMsg.contentBlocks?.find(
(b): b is Extract<ContentBlock, { type: 'summary_meta' }> => b.type === 'summary_meta'
)
const bufferBoundary = metaBlock?.bufferBoundaryTimestamp
if (!metaBlock) {
aiLogger.warn('Conversations', 'system message missing summary_meta; agent context will be summary-only', {
aiLogger.warn('Conversations', 'summary message missing summary_meta; agent context will be summary-only', {
conversationId,
messageId: systemMsg.id,
messageId: summaryMsg.id,
})
}
// 取 timestamp >= boundary 的 user/assistant 消息(buffer + 新消息)
const contextMessages = bufferBoundary
? validMessages.filter((m) => m.role !== 'system' && m.timestamp >= bufferBoundary)
? validMessages.filter((m) => m.role !== 'summary' && m.timestamp >= bufferBoundary)
: []
result = [
{ role: 'system' as const, content: systemMsg.content },
{ role: 'summary' as const, content: summaryMsg.content },
...contextMessages.map((m) => ({ role: m.role, content: m.content })),
]
} else {
@@ -565,7 +605,7 @@ export function getHistoryForAgent(
}
if (maxMessages && result.length > maxMessages) {
if (result.length > 0 && result[0].role === 'system') {
if (result.length > 0 && result[0].role === 'summary') {
const rest = result.slice(1)
const truncated = rest.slice(-(maxMessages - 1))
return [result[0], ...truncated]
@@ -578,7 +618,7 @@ export function getHistoryForAgent(
// ==================== Summary / 压缩专用 ====================
/**
* 添加 system 消息并替换旧的 system(每个对话只保留一条最新压缩摘要)
* 添加 summary 消息并替换旧的 summary(每个对话只保留一条最新压缩摘要)
*
* Summary 时间戳 = NOW(UI 中显示在触发压缩的位置)。
* Buffer 边界信息存入 content_blocks 的 summary_meta block 中,供 getHistoryForAgent 使用。
@@ -590,7 +630,7 @@ export function addSummaryMessage(
): AIMessage {
const db = getAiDb()
db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'system'").run(conversationId)
db.prepare("DELETE FROM ai_message WHERE conversation_id = ? AND role = 'summary'").run(conversationId)
const contentBlocks: ContentBlock[] = [
{
@@ -600,7 +640,7 @@ export function addSummaryMessage(
},
]
return addMessage(conversationId, 'system', content, undefined, undefined, contentBlocks)
return addMessage(conversationId, 'summary', content, undefined, undefined, contentBlocks)
}
/**
@@ -614,7 +654,7 @@ export function getLatestSummary(conversationId: string): AIMessage | null {
SELECT id, conversation_id as conversationId, role, content, timestamp,
data_keywords as dataKeywords, data_message_count as dataMessageCount, content_blocks as contentBlocks
FROM ai_message
WHERE conversation_id = ? AND role = 'system'
WHERE conversation_id = ? AND role = 'summary'
ORDER BY timestamp DESC
LIMIT 1
`
+11
View File
@@ -200,6 +200,17 @@ export function registerAIHandlers({ win }: IpcContext): void {
aiLogger.info('Config', `Debug mode ${enabled ? 'enabled' : 'disabled'}`)
})
ipcMain.handle('ai:clearDebugContext', async () => {
try {
const cleared = aiConversations.clearAllDebugContext()
aiLogger.info('Debug', `Cleared debug_context for ${cleared} messages`)
return { success: true, cleared }
} catch (error) {
console.error('Failed to clear debug context:', error)
throw error
}
})
// ==================== AI 对话管理 ====================
/**
+8 -1
View File
@@ -41,7 +41,7 @@ export type ContentBlock =
}
| { type: 'skill'; skillId: string; skillName: string }
export type AIMessageRole = 'user' | 'assistant' | 'system'
export type AIMessageRole = 'user' | 'assistant' | 'summary'
export interface TokenUsageData {
promptTokens: number
@@ -541,6 +541,13 @@ export const aiApi = {
return ipcRenderer.invoke('ai:showLogFile')
},
/**
* 一键清除所有消息的 debug_context 数据
*/
clearDebugContext: (): Promise<{ success: boolean; cleared: number }> => {
return ipcRenderer.invoke('ai:clearDebugContext')
},
getDefaultDesensitizeRules: (locale: string): Promise<DesensitizeRule[]> => {
return ipcRenderer.invoke('ai:getDefaultDesensitizeRules', locale)
},
+2 -1
View File
@@ -310,7 +310,7 @@ type AIContentBlock =
compressedMessageCount: number
}
type AIMessageRole = 'user' | 'assistant' | 'system'
type AIMessageRole = 'user' | 'assistant' | 'summary'
interface AITokenUsageData {
promptTokens: number
@@ -396,6 +396,7 @@ interface AiApi {
getConversationTokenUsage: (conversationId: string) => Promise<AITokenUsageData>
deleteMessage: (messageId: string) => Promise<boolean>
showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }>
clearDebugContext: () => Promise<{ success: boolean; cleared: number }>
getDefaultDesensitizeRules: (locale: string) => Promise<DesensitizeRule[]>
mergeDesensitizeRules: (existingRules: DesensitizeRule[], locale: string) => Promise<DesensitizeRule[]>
getToolCatalog: () => Promise<ToolCatalogEntry[]>
+6 -6
View File
@@ -13,7 +13,7 @@ const toast = useToast()
// Props
const props = defineProps<{
role: 'user' | 'assistant' | 'system'
role: 'user' | 'assistant' | 'summary'
content: string
timestamp: number
isStreaming?: boolean
@@ -30,7 +30,7 @@ const formattedTime = computed(() => {
// 是否是用户消息
const isUser = computed(() => props.role === 'user')
const isSystem = computed(() => props.role === 'system')
const isSummary = computed(() => props.role === 'summary')
// 创建 markdown-it 实例
const md = new MarkdownIt({
@@ -302,11 +302,11 @@ async function handleCopyMarkdown() {
</script>
<template>
<div class="flex items-start gap-3" :class="[isUser ? 'flex-row-reverse' : '', isSystem ? 'justify-center' : '']">
<div class="flex items-start gap-3" :class="[isUser ? 'flex-row-reverse' : '', isSummary ? 'justify-center' : '']">
<!-- 消息内容 -->
<div :class="[isSystem ? 'w-full min-w-0' : 'max-w-[85%] min-w-0']">
<div :class="[isSummary ? 'w-full min-w-0' : 'max-w-[85%] min-w-0']">
<!-- System 消息可折叠的上下文总结 -->
<template v-if="isSystem">
<template v-if="isSummary">
<details
class="w-full rounded-lg border border-gray-200 bg-gray-50/80 dark:border-gray-700/50 dark:bg-gray-800/40"
>
@@ -465,7 +465,7 @@ async function handleCopyMarkdown() {
<!-- 时间戳 + 操作按钮summary 消息和流式输出中不显示 -->
<div
v-if="!isSystem && !isStreaming"
v-if="!isSummary && !isStreaming"
class="mt-1 flex items-center gap-2 px-1"
:class="[isUser ? 'flex-row-reverse' : '']"
>
+28
View File
@@ -31,6 +31,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalRows.value / pageSi
const editingRowIndex = ref<number | null>(null)
const editingValues = ref<Record<string, string>>({})
const isClearingDebug = ref(false)
const isAiDb = computed(() => dbSource.value === 'ai')
@@ -240,6 +241,21 @@ function confirmDelete(rowIndex: number) {
}
}
async function handleClearDebugContext() {
if (!confirm(t('analysis.debug.tableBrowser.confirmClearDebug'))) return
isClearingDebug.value = true
try {
const res = await window.aiApi.clearDebugContext()
if (res.success) {
await loadTableData()
}
} catch (e: any) {
error.value = e.message || String(e)
} finally {
isClearingDebug.value = false
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}
@@ -342,6 +358,18 @@ onMounted(async () => {
<div class="flex-1" />
<UButton
v-if="isAiDb && selectedTable === 'ai_message'"
variant="ghost"
size="xs"
color="error"
icon="i-heroicons-trash"
:loading="isClearingDebug"
@click="handleClearDebugContext"
>
{{ t('analysis.debug.tableBrowser.clearDebugContext') }}
</UButton>
<UButton variant="ghost" size="xs" icon="i-heroicons-arrow-path" :loading="isLoading" @click="loadTableData">
{{ t('analysis.debug.tableBrowser.refresh') }}
</UButton>
+3 -1
View File
@@ -50,7 +50,9 @@
"allConversations": "All Conversations",
"copyCell": "Copy Selected",
"editRow": "Edit",
"copyRow": "Copy Row (JSON)"
"copyRow": "Copy Row (JSON)",
"clearDebugContext": "Clear Debug Context",
"confirmClearDebug": "Clear debug_context for all messages? This cannot be undone."
}
},
"memory": {
+3 -1
View File
@@ -50,7 +50,9 @@
"allConversations": "すべての会話",
"copyCell": "選択項目をコピー",
"editRow": "編集",
"copyRow": "行をコピー (JSON)"
"copyRow": "行をコピー (JSON)",
"clearDebugContext": "デバッグコンテキストをクリア",
"confirmClearDebug": "すべてのメッセージの debug_context を削除しますか?この操作は取り消せません。"
}
},
"memory": {
+3 -1
View File
@@ -50,7 +50,9 @@
"allConversations": "全部对话",
"copyCell": "复制选中项",
"editRow": "编辑",
"copyRow": "复制整行 (JSON)"
"copyRow": "复制整行 (JSON)",
"clearDebugContext": "清除 Debug 上下文",
"confirmClearDebug": "确认清除所有消息的 debug_context 数据?此操作不可撤销。"
}
},
"memory": {
+3 -1
View File
@@ -50,7 +50,9 @@
"allConversations": "全部對話",
"copyCell": "複製選取項",
"editRow": "編輯",
"copyRow": "複製整行 (JSON)"
"copyRow": "複製整行 (JSON)",
"clearDebugContext": "清除 Debug 上下文",
"confirmClearDebug": "確認清除所有訊息的 debug_context 資料?此操作不可撤銷。"
}
},
"memory": {
+5 -5
View File
@@ -59,7 +59,7 @@ export type ContentBlock =
// 消息类型
export interface ChatMessage {
id: string
role: 'user' | 'assistant' | 'system'
role: 'user' | 'assistant' | 'summary'
content: string
timestamp: number
dataSource?: {
@@ -870,14 +870,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
case 'compression_done':
if (chunk.compressionResult) {
const systemMsg: ChatMessage = {
id: `system-${Date.now()}`,
role: 'system',
const summaryMsg: ChatMessage = {
id: `summary-${Date.now()}`,
role: 'summary',
content: chunk.compressionResult.summaryContent,
timestamp: chunk.compressionResult.timestamp,
}
const insertIdx = Math.max(0, targetBuffer.messages.length - 1)
targetBuffer.messages.splice(insertIdx, 0, systemMsg)
targetBuffer.messages.splice(insertIdx, 0, summaryMsg)
aiMessageIndex++
}
break