feat(ai): 新增 AI 消息流诊断探针

- 新增 inspect_ai_message_flow 内置工具

- 识别连续 assistant 气泡、空消息和未闭环工具调用

- 同步工具目录、系统引导、执行状态文案和回归测试
This commit is contained in:
Syngnat
2026-06-10 12:59:09 +08:00
parent e16082af9a
commit 8ddd8a726d
11 changed files with 288 additions and 5 deletions

View File

@@ -75,6 +75,8 @@ describe('AIBuiltinToolsCatalog', () => {
expect(markup).toContain('inspect_recent_connection_failures');
expect(markup).toContain('排查 AI 气泡渲染异常');
expect(markup).toContain('inspect_ai_last_render_error');
expect(markup).toContain('诊断 AI 消息流');
expect(markup).toContain('inspect_ai_message_flow');
expect(markup).toContain('复用历史 SQL');
expect(markup).toContain('inspect_saved_queries');
expect(markup).toContain('回看 AI 历史对话');

View File

@@ -42,8 +42,8 @@ const BUILTIN_TOOL_FLOWS = [
},
{
title: 'AI 应用健康总览',
steps: 'inspect_app_health → inspect_ai_setup_health / inspect_app_logs / inspect_recent_connection_failures / inspect_ai_last_render_error',
description: '适合用户反馈 AI 不稳定、连接和 MCP 问题交织、回复气泡显示异常,或需要先看整体健康状态时,一次汇总配置、日志、连接失败、渲染异常和工作区现场。',
steps: 'inspect_app_health → inspect_ai_setup_health / inspect_app_logs / inspect_recent_connection_failures / inspect_ai_last_render_error / inspect_ai_message_flow',
description: '适合用户反馈 AI 不稳定、连接和 MCP 问题交织、回复气泡显示异常,或需要先看整体健康状态时,一次汇总配置、日志、连接失败、渲染异常、消息流和工作区现场。',
},
{
title: '一键体检 AI 配置',
@@ -160,6 +160,11 @@ const BUILTIN_TOOL_FLOWS = [
steps: 'inspect_ai_last_render_error → inspect_active_tab / inspect_ai_runtime',
description: '适合用户反馈 AI 某条消息空白、气泡局部报错但整个面板没挂时,先拿到最近一次被隔离的渲染异常快照,再回到具体会话和运行时上下文继续缩小范围。',
},
{
title: '诊断 AI 消息流',
steps: 'inspect_ai_message_flow → inspect_ai_last_render_error / inspect_app_logs',
description: '适合用户反馈回复被拆成多个气泡、工具调用后没继续回答、消息流状态不对时,先读取当前会话的真实消息结构和异常信号。',
},
{
title: '复用历史 SQL',
steps: 'inspect_saved_queries → get_columns / execute_sql',

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildAIChatSessionsSnapshot } from './aiChatSessionInsights';
import {
buildAIChatSessionsSnapshot,
buildAIMessageFlowSnapshot,
} from './aiChatSessionInsights';
describe('aiChatSessionInsights', () => {
it('filters and summarizes ai sessions with previews from local history', () => {
@@ -34,4 +37,42 @@ describe('aiChatSessionInsights', () => {
latestMessagePreview: '先检查支付回调日志',
});
});
it('diagnoses active ai message flow anomalies', () => {
const snapshot = buildAIMessageFlowSnapshot({
aiChatSessions: [
{ id: 'session-1', title: '气泡异常排查', updatedAt: 300 },
],
aiChatHistory: {
'session-1': [
{ id: 'msg-1', role: 'user', content: '为什么回复变成多个气泡', timestamp: 101 },
{
id: 'msg-2',
role: 'assistant',
content: '我先检查消息流',
timestamp: 102,
tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_ai_runtime', arguments: '{}' } }],
},
{ id: 'msg-3', role: 'assistant', content: '这里被拆成了第二个气泡', timestamp: 103 },
{ id: 'msg-4', role: 'assistant', content: '', timestamp: 104 },
],
},
activeSessionId: 'session-1',
limit: 10,
});
expect(snapshot.found).toBe(true);
expect(snapshot.title).toBe('气泡异常排查');
expect(snapshot.totalMessages).toBe(4);
expect(snapshot.unresolvedToolCallCount).toBe(1);
expect(snapshot.consecutiveAssistantPairCount).toBe(2);
expect(snapshot.emptyAssistantMessageCount).toBe(1);
expect(snapshot.warnings).toContain('有 1 个工具调用没有匹配到 tool 结果消息');
expect(snapshot.nextActions).toContain('检查流式追加逻辑是否复用了同一个 assistantMsgId而不是为同一轮回复新建 assistant 消息');
expect(snapshot.messages[1]).toMatchObject({
id: 'msg-2',
toolCallNames: ['inspect_ai_runtime'],
toolCallIds: ['tool-1'],
});
});
});

View File

@@ -7,6 +7,7 @@ interface AIChatSessionMeta {
}
const AI_CHAT_SESSION_PREVIEW_LIMIT = 240;
const AI_MESSAGE_FLOW_PREVIEW_LIMIT = 180;
const normalizeLimit = (input: unknown, fallback: number, max: number): number => {
const value = Math.floor(Number(input) || fallback);
@@ -134,3 +135,138 @@ export const buildAIChatSessionsSnapshot = (params: {
sessions: visibleSessions,
};
};
const buildMessagePreview = (message: AIChatMessage, previewLimit: number): string => {
const raw = String(message.content || message.reasoning_content || '').trim();
return raw.slice(0, previewLimit);
};
const getToolCallNames = (message: AIChatMessage): string[] => (
(message.tool_calls || [])
.map((toolCall) => String(toolCall?.function?.name || '').trim())
.filter(Boolean)
);
export const buildAIMessageFlowSnapshot = (params: {
aiChatSessions?: AIChatSessionMeta[];
aiChatHistory?: Record<string, AIChatMessage[]>;
activeSessionId?: string | null;
sessionId?: unknown;
limit?: unknown;
includeContent?: unknown;
previewLimit?: unknown;
}) => {
const {
aiChatSessions = [],
aiChatHistory = {},
activeSessionId = null,
sessionId,
limit,
includeContent = true,
previewLimit,
} = params;
const requestedSessionId = String(sessionId || activeSessionId || '').trim();
const safeLimit = normalizeLimit(limit, 24, 80);
const safePreviewLimit = normalizeLimit(previewLimit, AI_MESSAGE_FLOW_PREVIEW_LIMIT, 1000);
const shouldIncludeContent = includeContent !== false;
const sessionMetaMap = new Map(aiChatSessions.map((session) => [session.id, session]));
const messages = requestedSessionId
? [...(aiChatHistory[requestedSessionId] || [])].sort((left, right) => left.timestamp - right.timestamp)
: [];
const toolResultsByCallId = new Map(
messages
.filter((message) => message.role === 'tool' && message.tool_call_id)
.map((message) => [String(message.tool_call_id), message]),
);
const assistantMessages = messages.filter((message) => message.role === 'assistant');
const toolCallMessages = assistantMessages.filter((message) => (message.tool_calls || []).length > 0);
const unresolvedToolCalls = toolCallMessages.flatMap((message) =>
(message.tool_calls || [])
.filter((toolCall) => !toolResultsByCallId.has(toolCall.id))
.map((toolCall) => ({
assistantMessageId: message.id,
toolCallId: toolCall.id,
toolName: toolCall.function?.name || '',
})),
);
const emptyAssistantMessages = assistantMessages.filter((message) =>
!String(message.content || '').trim()
&& !String(message.reasoning_content || '').trim()
&& !(message.tool_calls || []).length
&& !message.loading,
);
const consecutiveAssistantPairs: Array<{ previousMessageId: string; nextMessageId: string }> = [];
for (let index = 1; index < messages.length; index += 1) {
if (messages[index - 1]?.role === 'assistant' && messages[index]?.role === 'assistant') {
consecutiveAssistantPairs.push({
previousMessageId: messages[index - 1].id,
nextMessageId: messages[index].id,
});
}
}
const warnings = [
unresolvedToolCalls.length > 0 ? `${unresolvedToolCalls.length} 个工具调用没有匹配到 tool 结果消息` : '',
consecutiveAssistantPairs.length > 0 ? `发现 ${consecutiveAssistantPairs.length} 组连续 assistant 消息,可能存在回复被拆成多个气泡` : '',
emptyAssistantMessages.length > 0 ? `发现 ${emptyAssistantMessages.length} 条空 assistant 消息` : '',
messages.some((message) => message.loading) ? '会话中仍有 loading 消息,可能还在流式生成或上次中断未清理' : '',
].filter(Boolean);
const nextActions = [
unresolvedToolCalls.length > 0 ? '优先核对 useAIChatLocalTools 是否为每个 tool_call_id 写入 tool 消息' : '',
consecutiveAssistantPairs.length > 0 ? '检查流式追加逻辑是否复用了同一个 assistantMsgId而不是为同一轮回复新建 assistant 消息' : '',
emptyAssistantMessages.length > 0 ? '检查异常或取消路径是否留下了空 assistant 占位消息' : '',
warnings.length === 0 ? '消息流未发现明显结构异常,可继续结合 inspect_ai_last_render_error 或 inspect_app_logs 排查渲染/运行时问题' : '',
].filter(Boolean);
const recentMessages = messages.slice(-safeLimit).map((message) => {
const preview = shouldIncludeContent ? buildMessagePreview(message, safePreviewLimit) : '';
const toolCallNames = getToolCallNames(message);
return {
id: message.id,
role: message.role,
phase: message.phase || '',
timestamp: message.timestamp,
loading: Boolean(message.loading),
contentLength: String(message.content || '').length,
reasoningLength: String(message.reasoning_content || '').length,
preview,
previewTruncated: shouldIncludeContent
&& String(message.content || message.reasoning_content || '').trim().length > preview.length,
toolCallCount: (message.tool_calls || []).length,
toolCallNames,
toolCallIds: (message.tool_calls || []).map((toolCall) => toolCall.id),
toolCallId: message.tool_call_id || '',
toolName: message.tool_name || '',
success: message.success,
};
});
const meta = requestedSessionId ? sessionMetaMap.get(requestedSessionId) : undefined;
return {
activeSessionId: activeSessionId || '',
requestedSessionId,
found: Boolean(requestedSessionId && (messages.length > 0 || meta)),
title: String(meta?.title || '').trim(),
updatedAt: Number(meta?.updatedAt || messages[messages.length - 1]?.timestamp || 0),
totalMessages: messages.length,
returnedMessages: recentMessages.length,
truncated: messages.length > recentMessages.length,
userMessageCount: messages.filter((message) => message.role === 'user').length,
assistantMessageCount: assistantMessages.length,
toolMessageCount: messages.filter((message) => message.role === 'tool').length,
systemMessageCount: messages.filter((message) => message.role === 'system').length,
assistantToolCallMessageCount: toolCallMessages.length,
unresolvedToolCallCount: unresolvedToolCalls.length,
emptyAssistantMessageCount: emptyAssistantMessages.length,
consecutiveAssistantPairCount: consecutiveAssistantPairs.length,
unresolvedToolCalls,
consecutiveAssistantPairs,
warnings,
nextActions,
messages: recentMessages,
};
};

View File

@@ -101,6 +101,44 @@ describe('aiLocalToolExecutor local asset inspection tools', () => {
expect(result.content).not.toContain('列出最近注册用户');
});
it('returns ai message flow diagnostics for the active session', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_ai_message_flow', {
limit: 8,
}),
connections: [buildConnection()],
mcpTools: [],
toolContextMap: new Map(),
aiChatSessions: [
{ id: 'session-1', title: '消息流异常', updatedAt: 200 },
],
aiChatHistory: {
'session-1': [
{ id: 'msg-1', role: 'user', content: 'AI 回复拆成多个气泡', timestamp: 101 },
{
id: 'msg-2',
role: 'assistant',
content: '先调用探针',
timestamp: 102,
tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_ai_runtime', arguments: '{}' } }],
},
{ id: 'msg-3', role: 'assistant', content: '继续回答', timestamp: 103 },
],
},
activeSessionId: 'session-1',
runtime: {
getDatabases: vi.fn(),
getTables: vi.fn(),
},
});
expect(result.success).toBe(true);
expect(result.content).toContain('"requestedSessionId":"session-1"');
expect(result.content).toContain('"unresolvedToolCallCount":1');
expect(result.content).toContain('"consecutiveAssistantPairCount":1');
expect(result.content).toContain('回复拆成多个气泡');
});
it('returns sql snippets so the model can inspect local query templates', async () => {
const result = await executeLocalAIToolCall({
toolCall: buildToolCall('inspect_sql_snippets', {

View File

@@ -12,7 +12,10 @@ import type {
} from '../../types';
import type { SqlLog } from '../../store';
import { buildAIContextSnapshot } from './aiContextInsights';
import { buildAIChatSessionsSnapshot } from './aiChatSessionInsights';
import {
buildAIChatSessionsSnapshot,
buildAIMessageFlowSnapshot,
} from './aiChatSessionInsights';
import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights';
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
import {
@@ -262,6 +265,19 @@ export async function executeSnapshotInspectionToolCall(
})),
success: true,
};
case 'inspect_ai_message_flow':
return {
content: JSON.stringify(buildAIMessageFlowSnapshot({
aiChatSessions,
aiChatHistory,
activeSessionId,
sessionId: args.sessionId,
limit: args.limit,
includeContent: args.includeContent !== false,
previewLimit: args.previewLimit,
})),
success: true,
};
case 'inspect_recent_sql_logs':
return {
content: JSON.stringify(buildRecentSqlLogsSnapshot({
@@ -390,6 +406,7 @@ export async function executeSnapshotInspectionToolCall(
inspect_app_logs: '读取 GoNavi 应用日志失败',
inspect_recent_connection_failures: '汇总最近连接失败记录失败',
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败',
inspect_ai_message_flow: '读取 AI 消息流诊断失败',
inspect_saved_queries: '读取已保存查询失败',
inspect_sql_snippets: '读取 SQL 片段失败',
inspect_shortcuts: '读取快捷键配置失败',

View File

@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
connections: [connections[0]],
tabs: [],
activeTabId: null,
availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_mcp_authoring_guide', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'],
skills,
userPromptSettings,
});
@@ -95,6 +95,7 @@ describe('buildAISystemContextMessages', () => {
expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结');
expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部');
expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录');
expect(joined).toContain('inspect_ai_message_flow 读取当前会话的真实消息结构');
expect(joined).toContain('inspect_saved_queries');
expect(joined).toContain('inspect_ai_sessions');
expect(joined).toContain('inspect_sql_snippets');

View File

@@ -122,6 +122,12 @@ export const appendDatabaseInspectionGuidanceMessages = (
'inspect_ai_last_render_error',
'如果用户提到“AI 某条消息空白了”“某个气泡渲染失败”“消息块局部报错但面板没全挂”,优先调用 inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录,不要只凭截图现象猜测。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,
'inspect_ai_message_flow',
'如果用户提到“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流状态不对”“同一轮回答没有追加到同一个气泡”,优先调用 inspect_ai_message_flow 读取当前会话的真实消息结构、连续 assistant 消息和未闭环工具调用,不要只凭界面现象猜测。',
);
appendGuidanceIfToolAvailable(
messages,
availableToolNames,

View File

@@ -54,6 +54,7 @@ const TOOL_ACTION_LABELS: Record<string, string> = {
inspect_app_logs: '回看 GoNavi 应用日志',
inspect_recent_connection_failures: '总结最近连接失败记录',
inspect_ai_last_render_error: '读取最近一次 AI 渲染异常',
inspect_ai_message_flow: '诊断当前 AI 消息流',
inspect_saved_queries: '检索本地已保存查询',
inspect_sql_snippets: '读取 SQL 片段模板',
inspect_shortcuts: '读取当前快捷键配置',

View File

@@ -539,6 +539,31 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_ai_message_flow",
icon: "🧬",
desc: "诊断当前 AI 会话消息流",
detail:
"读取当前或指定 AI 会话的最近消息流,统计用户/助手/tool 消息、工具调用是否都有结果、是否出现连续 assistant 气泡、空 assistant 占位或未清理 loading。适合用户反馈“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流看着不对”时先看真实消息结构。",
params: "sessionId?(默认当前会话), limit?(默认 24), includeContent?(默认 true), previewLimit?(默认 180)",
tool: {
type: "function",
function: {
name: "inspect_ai_message_flow",
description:
"读取当前或指定 AI 会话的最近消息流诊断包括消息角色序列、assistant/tool 消息数量、工具调用与 tool 结果匹配情况、连续 assistant 消息、空 assistant 消息和 loading 残留。适用于用户提到 AI 回复被拆成多个气泡、流式追加异常、工具调用没有闭环、某轮回答没有继续生成时,先读取真实消息结构再定位。",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID不传时读取当前活动会话" },
limit: { type: "number", description: "可选,最多返回最近多少条消息,默认 24最大 80" },
includeContent: { type: "boolean", description: "可选,是否附带消息内容预览,默认 true" },
previewLimit: { type: "number", description: "可选,每条消息预览字符数,默认 180最大 1000" },
},
},
},
},
},
{
name: "inspect_sql_snippets",
icon: "🧩",

View File

@@ -122,12 +122,20 @@ describe('aiToolRegistry', () => {
expect(info?.tool.function.description).toContain('消息渲染异常');
});
it('registers the ai-message-flow inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_message_flow');
expect(info).toBeTruthy();
expect(info?.desc).toContain('消息流');
expect(info?.tool.function.description).toContain('连续 assistant 消息');
});
it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => {
const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity');
const sqlRiskTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_risk');
const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
const messageFlowTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_message_flow');
const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries');
const aiSessionsTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_sessions');
const snippetTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_snippets');
@@ -142,6 +150,8 @@ describe('aiToolRegistry', () => {
expect(connectionFailureTool?.tool.function.description).toContain('连接冷却');
expect(renderErrorTool?.desc).toContain('渲染异常记录');
expect(renderErrorTool?.tool.function.description).toContain('气泡局部报错');
expect(messageFlowTool?.desc).toContain('消息流');
expect(messageFlowTool?.tool.function.description).toContain('工具调用没有闭环');
expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询');
expect(savedQueryTool?.tool.function.description).toContain('历史查询');
expect(aiSessionsTool?.desc).toContain('AI 历史会话');
@@ -184,6 +194,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_message_flow')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true);