diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aea2de9592..d89c7ccf9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: - name: Run SQL clipboard test run: yarn run test:sql-in-clipboard + - name: Run agent chat tests + run: yarn run test:agent-chat + - name: Run result Markdown formatter test run: yarn run test:result-markdown diff --git a/AGENTS.md b/AGENTS.md index 0c08b8c692..dd0e243d4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,9 @@ Instruction authority and factual evidence are separate: JCEF, SPI, plugin, tools, and startup modules. - `chat2db-community-server/chat2db-community-start/`: executable backend, configuration, assembled frontend resources, and final jar. +- `chat2db-community-server/chat2db-community-agent/`: agent runtime adapters, + installation, process/RPC transport, and runtime resources. It depends only + on tools and third-party libraries; start assembles it with business services. - `chat2db-community-server/chat2db-community-plugins/`: database-specific implementations. Keep dialect-specific behavior inside the owning plugin. - `docker/`: Community image, Compose file, and local image build entry point. diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index 9811f43973..2f552865b1 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -12,7 +12,7 @@ "build": "yarn run build:web:community", "build:prod": "yarn run build:web:community", "build:web": "umi build", - "prebuild:web:community": "yarn test:ipc-log-redaction && yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:update-check-schedule && yarn test:task-center && yarn test:import-preview && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request && yarn test:connection-close-request && yarn test:ssh-connection && yarn test:operation-log-filters && yarn test:dashboard-store", + "prebuild:web:community": "yarn test:ipc-log-redaction && yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:update-check-schedule && yarn test:task-center && yarn test:import-preview && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request && yarn test:connection-close-request && yarn test:ssh-connection && yarn test:operation-log-filters && yarn test:dashboard-store && yarn test:agent-chat", "postbuild:web:community": "node ./scripts/verify-production-bundles.cjs", "build:web:2java": "yarn run build:web:community && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/", "build:web:community": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", @@ -35,6 +35,9 @@ "test:tree-node-path": "tsx src/blocks/NewTree/utils/treeNodePath.test.ts", "test:ai-model-config": "tsx src/blocks/AI/components/AIModelConfigModal/modelConfigDefaults.test.ts", "test:ai-model-select": "tsx src/blocks/AI/components/AIModelSelect/modelSelectOptions.test.ts", + "test:desktop-request": "tsx src/service/commandLine/commandLineRequest.test.ts", + "test:agent-chat": "cross-env NODE_ENV=test yarn run test:agent-chat:suite", + "test:agent-chat:suite": "yarn test:desktop-request && yarn test:pi-adapters && tsx src/blocks/AI/agentOutput.test.ts && tsx src/blocks/AI/components/AgentV2Session/useOutputPage.test.tsx && tsx src/blocks/AI/components/AIAtMetion/useActive.test.tsx && tsx src/blocks/AI/components/AgentV2Session/presentation.test.ts && tsx src/blocks/AI/components/AIChatInput/inputSuggestions.test.ts && tsx src/blocks/AI/agentContext.test.ts && tsx src/blocks/AI/components/AIChatInput/mentionSelection.test.ts && tsx src/blocks/AI/agentCharts.test.ts && tsx src/blocks/AI/components/AgentChartCard/option.test.ts && tsx src/blocks/AI/agentEvents.test.ts && tsx src/blocks/AI/agentQuestions.test.ts && tsx src/blocks/AI/agentEventStream.test.ts && tsx src/blocks/AI/chatSessionRoute.test.ts && tsx src/blocks/AI/components/PiToolSettings/model.test.ts && tsx src/utils/confirmBetaFeature.test.ts", "test:active-tab-locator": "tsx src/pages/main/workspace/utils/activeTabLocator.test.ts", "test:application-exit": "tsx src/layouts/init/applicationExitCoordinator.test.ts", "test:base-table-interaction": "tsx src/components/BaseTable/treeInteraction.test.ts", @@ -109,7 +112,8 @@ "start": "yarn run start:community:hot", "start:community:hot": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env DISABLE_MFSU=true cross-env UMI_DEV_SERVER_COMPRESS=none cross-env HOST=127.0.0.1 cross-env PORT=8889 node --require ./scripts/bind-dev-server-loopback.cjs ./node_modules/umi/bin/umi.js dev --public_path=/", "ui": "yarn add @chat2db/ui", - "test:create-stream-index": "tsx src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts" + "test:create-stream-index": "tsx src/blocks/RedisAllData/CreateStream/mapDisplayedIndex.test.ts", + "test:pi-adapters": "tsx src/service/pi/client.test.ts" }, "dependencies": { "@chat2db/ui": "^1.45.3", diff --git a/chat2db-community-client/scripts/i18n-source-hashes.json b/chat2db-community-client/scripts/i18n-source-hashes.json index 3ad5420ea7..ac01e31279 100644 --- a/chat2db-community-client/scripts/i18n-source-hashes.json +++ b/chat2db-community-client/scripts/i18n-source-hashes.json @@ -17,10 +17,10 @@ "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "redis.ts": "cacf9cf685bf3bf6d019f18eead0f63713d4727be741a87d8dc1d22807ebd9ad", - "setting.ts": "c256e93a1c6ee99eac34f0d22cea8b5c0609b4cb0960af56198e663bf3e3f93c", + "setting.ts": "8422043f7352260c681fac02550af8cec5524df0409ff2fdaa9434bda04957c9", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", - "stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5", + "stream.ts": "8c870a19ec607a359ed9a0fbfe711554cbb07c9c460414c371a350fb7b0ea405", "workspace.ts": "f41dcc31f6fa7de500cda95ef550737c99606569077d26150239b4ce14db7ef1" }, "ko-KR": { @@ -38,10 +38,10 @@ "notification.ts": "500751da2f02391622f074448eb46611fd41da2e5530d6c7d65f17a1382bd236", "plugin.ts": "01f15b6342cc5b29899ee4144d67b04bb4bc02c6bb10779806be322dc5e9a2bc", "redis.ts": "cacf9cf685bf3bf6d019f18eead0f63713d4727be741a87d8dc1d22807ebd9ad", - "setting.ts": "c256e93a1c6ee99eac34f0d22cea8b5c0609b4cb0960af56198e663bf3e3f93c", + "setting.ts": "8422043f7352260c681fac02550af8cec5524df0409ff2fdaa9434bda04957c9", "spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980", "sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9", - "stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5", + "stream.ts": "8c870a19ec607a359ed9a0fbfe711554cbb07c9c460414c371a350fb7b0ea405", "workspace.ts": "f41dcc31f6fa7de500cda95ef550737c99606569077d26150239b4ce14db7ef1" } } diff --git a/chat2db-community-client/src/blocks/AI/agentCharts.test.ts b/chat2db-community-client/src/blocks/AI/agentCharts.test.ts new file mode 100644 index 0000000000..caf2da2d28 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentCharts.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import type { AgentEvent } from '@/service/agent'; +import { agentChartDetail, isPartialChart, updateAgentCharts, usesGroupedAgentChart } from './agentCharts'; +import { buildAgentChartOption } from './components/AgentChartCard/option'; + +const payload = { + id: 'chart-1', runId: 'run-1', resultId: 'result-1', chartType: 'Line', title: 'Same title', + xField: 'month', yField: 'value', series: [], warnings: [], + data: [{ month: 'Jan', value: 0.1 }, { month: 'Feb', value: null }], page: { number: 1, hasMore: false }, +}; +const event = (chart: unknown, sequence = 1): AgentEvent => ({ + id: `event-${sequence}`, sessionId: 'session', runId: 'run-1', sequence, + type: 'CHART_CREATED', payload: { chart }, occurredAt: '', +}); +const first = updateAgentCharts([], [event(payload)]); +assert.equal(first.length, 1); +assert.strictEqual(updateAgentCharts(first, [event(payload)]), first); +const second = updateAgentCharts(first, [event({ ...payload, id: 'chart-2' }, 2)]); +assert.equal(second.length, 2, 'Equal titles do not merge distinct charts'); +assert.deepEqual(agentChartDetail(first[0]).chartSchema?.data, payload.data); +assert.equal(agentChartDetail(first[0]).chartSchema?.data[1].value, null); +assert.equal(agentChartDetail({ ...first[0], chartType: 'Combo' }).chartSchema?.chartOptionCheckbox.includes('showLabel'), false); +assert.equal(isPartialChart(first[0]), false); +assert.equal(isPartialChart({ ...first[0], page: { number: 2, hasMore: false } }), true); +assert.equal(isPartialChart({ ...first[0], page: { number: 1, hasMore: null } }), true); +assert.deepEqual(updateAgentCharts([], [event({ ...payload, chartType: 'Bad' }), event({ ...payload, runId: 'foreign' })]), []); +assert.equal(updateAgentCharts([], [event({ ...payload, chartType: 'Statistics', xField: undefined, page: undefined })]).length, 1); +assert.equal(updateAgentCharts([], [event({ ...payload, data: [{ value: Number.NaN }] })]).length, 0); +assert.equal(usesGroupedAgentChart(first[0]), false, 'Historic charts keep the existing renderer'); +assert.equal(updateAgentCharts([], [event({ ...payload, groupBy: null, stack: null })]).length, 1); +const groupedPayload = { + ...payload, chartType: 'Column', groupBy: ['region'], stack: true, + data: [{ month: 'Jan', region: '华东', value: 10 }, { month: 'Jan', region: '华西', value: 20 }], +}; +const restored = updateAgentCharts([], JSON.parse(JSON.stringify([event(groupedPayload)]))); +assert.equal(restored.length, 1); +assert.deepEqual(restored[0].groupBy, ['region']); +assert.equal(restored[0].stack, true); +assert.equal(usesGroupedAgentChart(restored[0]), true); +assert.deepEqual(buildAgentChartOption(restored[0], { border: '#ddd', text: '#111' }) + .series.map((series) => series.data), [[10], [20]], 'Restored event payloads render the same groups'); +for (const invalid of [ + { groupBy: 'region' }, { groupBy: ['region', 'region'] }, { groupBy: [''] }, { groupBy: [null] }, + { groupBy: ['a', 'b', 'c', 'd'] }, { groupBy: ['month'] }, { groupBy: ['value'] }, { groupBy: ['missing'] }, + { stack: 'true' }, { stack: 1 }, { chartType: 'Pie' }, { chartType: 'Statistics' }, + { chartType: 'Line' }, { chartType: 'Scatter' }, + { data: [...groupedPayload.data, groupedPayload.data[0]] }, +]) { + assert.equal(updateAgentCharts([], [event({ ...groupedPayload, ...invalid })]).length, 0, + `Reject invalid group/stack payload ${JSON.stringify(invalid)}`); +} +for (const chartType of ['Line', 'Scatter', 'AreaLine', 'Bar']) { + assert.equal(updateAgentCharts([], [event({ ...groupedPayload, chartType, stack: false })]).length, 1); +} +assert.equal(updateAgentCharts([], [event({ + ...groupedPayload, chartType: 'Scatter', stack: false, + data: [...groupedPayload.data, { ...groupedPayload.data[0], value: 15 }], +})]).length, 1, 'Scatter allows repeated X values for the same group'); +const seriesLimit = (count: number) => ({ + ...groupedPayload, + data: Array.from({ length: count }, (_, index) => ({ month: 'Jan', region: index, value: 1 })), +}); +assert.equal(updateAgentCharts([], [event(seriesLimit(32))]).length, 1); +assert.equal(updateAgentCharts([], [event(seriesLimit(33))]).length, 0); +const comboPayload = { + ...groupedPayload, chartType: 'Combo', + series: [ + { field: 'value', chartType: 'Column', axisPosition: 'left' }, + { field: 'count', chartType: 'Line', axisPosition: 'right' }, + ], +}; +assert.equal(updateAgentCharts([], [event({ ...comboPayload, data: seriesLimit(16).data })]).length, 1); +assert.equal(updateAgentCharts([], [event({ ...comboPayload, data: seriesLimit(17).data })]).length, 0); +assert.equal(updateAgentCharts([], [event({ + ...comboPayload, groupBy: [], data: [groupedPayload.data[0]], +})]).length, 1); +assert.equal(updateAgentCharts([], [event({ + ...comboPayload, series: [comboPayload.series[0], comboPayload.series[0]], +})]).length, 0, 'Duplicate metric fields cannot create duplicate ECharts series IDs'); +assert.equal(updateAgentCharts([], [event({ + ...comboPayload, series: [{ field: 'value', chartType: 'Line', axisPosition: 'left' }], +})]).length, 0, 'Stacked Combo must contain a column or area series'); +for (const locale of ['zh-CN', 'en-US', 'es-ES', 'ja-JP', 'ko-KR']) { + const source = readFileSync(new URL(`../../i18n/${locale}/stream.ts`, import.meta.url), 'utf8'); + for (const key of ['partialResult', 'viewQueryData', 'queryData']) assert.ok(source.includes(`stream.chart.${key}`)); +} +console.log('Agent chart restoration, group/stack schema, identity, series limits and locale coverage passed.'); diff --git a/chat2db-community-client/src/blocks/AI/agentCharts.ts b/chat2db-community-client/src/blocks/AI/agentCharts.ts new file mode 100644 index 0000000000..c9058d8838 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentCharts.ts @@ -0,0 +1,107 @@ +import type { AgentEvent } from '@/service/agent'; +import { ChartType, LineType, OrderByRule, OrderByType } from '@/blocks/BI/Chart/constants'; +import type { IChartItem } from '@/typings/dashboard'; + +const chartTypes = { + Column: ChartType.Column, Bar: ChartType.Bar, Line: ChartType.Line, AreaLine: ChartType.AreaLine, + Pie: ChartType.Pie, RingPie: ChartType.RingPie, RosePie: ChartType.RosePie, Funnel: ChartType.Funnel, + Scatter: ChartType.Scatter, Statistics: ChartType.Statistics, Combo: ChartType.Combo, +}; + +export interface AgentChart { + id: string; + runId: string; + resultId: string; + chartType: keyof typeof chartTypes; + title: string; + xField?: string | null; + yField?: string | null; + groupBy?: string[] | null; + stack?: boolean | null; + series: { field: string; chartType: 'Column' | 'Line' | 'AreaLine' | 'Scatter'; axisPosition: 'left' | 'right' }[]; + data: Record[]; + page?: { number: number; hasMore?: boolean | null } | null; + warnings: string[]; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export const usesGroupedAgentChart = (chart: AgentChart) => Boolean(chart.groupBy?.length || chart.stack); + +const hasValidGrouping = (chart: AgentChart): boolean => { + if (!usesGroupedAgentChart(chart)) return true; + const groupBy = chart.groupBy ?? []; + const metrics = chart.chartType === 'Combo' ? chart.series.map((series) => series.field) : [chart.yField]; + if (!['Column', 'Bar', 'Line', 'AreaLine', 'Scatter', 'Combo'].includes(chart.chartType) + || !chart.xField || !metrics.length || metrics.length > 8 || metrics.some((field) => !field) + || new Set(metrics).size !== metrics.length || metrics.includes(chart.xField) + || groupBy.some((field) => field === chart.xField || metrics.includes(field))) return false; + if (chart.stack && !(['Column', 'Bar', 'AreaLine'].includes(chart.chartType) + || chart.chartType === 'Combo' && chart.series.some((series) => ['Column', 'AreaLine'].includes(series.chartType)))) { + return false; + } + const groups = new Set(); + const grains = new Set(); + for (const row of chart.data) { + if (groupBy.some((field) => !Object.hasOwn(row, field)) || !Object.hasOwn(row, chart.xField)) return false; + const tuple = groupBy.map((field) => row[field]); + const grain = JSON.stringify([row[chart.xField], ...tuple]); + if (chart.chartType !== 'Scatter' && grains.has(grain)) return false; + grains.add(grain); + groups.add(JSON.stringify(tuple)); + } + return groups.size * metrics.length <= 32; +}; + +const hasAgentChartShape = (value: unknown): value is AgentChart => { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.runId !== 'string' + || typeof value.resultId !== 'string' || typeof value.title !== 'string' + || typeof value.chartType !== 'string' || !Object.hasOwn(chartTypes, value.chartType) + || !(value.xField == null || typeof value.xField === 'string') + || !(value.yField == null || typeof value.yField === 'string') + || !(value.groupBy == null || Array.isArray(value.groupBy) + && value.groupBy.length <= 3 + && value.groupBy.every((field) => typeof field === 'string' && field.trim().length > 0) + && new Set(value.groupBy).size === value.groupBy.length) + || !(value.stack == null || typeof value.stack === 'boolean')) return false; + return Array.isArray(value.data) && value.data.every((row) => isRecord(row) + && Object.values(row).every((cell) => cell === null || typeof cell === 'string' + || typeof cell === 'number' && Number.isFinite(cell))) + && Array.isArray(value.series) && value.series.every((series) => isRecord(series) + && typeof series.field === 'string' && ['Column', 'Line', 'AreaLine', 'Scatter'].includes(String(series.chartType)) + && ['left', 'right'].includes(String(series.axisPosition))) + && (value.page == null || isRecord(value.page) && typeof value.page.number === 'number' + && (value.page.hasMore == null || typeof value.page.hasMore === 'boolean')) + && Array.isArray(value.warnings) && value.warnings.every((warning) => typeof warning === 'string'); +}; + +const isAgentChart = (value: unknown): value is AgentChart => hasAgentChartShape(value) && hasValidGrouping(value); + +export const updateAgentCharts = (current: AgentChart[], events: AgentEvent[]): AgentChart[] => { + const additions = events.filter((event) => event.type === 'CHART_CREATED') + .map((event) => ({ event, chart: event.payload.chart })) + .filter((item): item is { event: AgentEvent; chart: AgentChart } => + isAgentChart(item.chart) && item.chart.runId === item.event.runId); + if (!additions.length) return current; + const charts = new Map(current.map((chart) => [chart.id, chart])); + additions.forEach(({ chart }) => { if (!charts.has(chart.id)) charts.set(chart.id, chart); }); + return charts.size === current.length ? current : [...charts.values()]; +}; + +export const isPartialChart = (chart: AgentChart) => + chart.page != null && (chart.page.number > 1 || chart.page.hasMore !== false); + +export const agentChartDetail = (chart: AgentChart): IChartItem => ({ + chartSchema: { + chartType: chartTypes[chart.chartType], title: chart.title, + xField: chart.xField, yField: chart.yField, + angleField: chart.xField, valueField: chart.yField, + comboYAxisData: chart.series.map((series) => ({ ...series, chartType: chartTypes[series.chartType] })), + data: chart.data, lineType: LineType.Straight, orderByType: OrderByType.DEFAULT, + orderByRule: OrderByRule.DESC, themeColorCode: 'v1-baby-blue', + chartOptionCheckbox: chart.chartType === 'Combo' + ? ['showLegend', 'showAxisLine', 'showSplitLine', 'showSymbol'] + : ['showLegend', 'showLabel', 'showAxisLine', 'showSplitLine', 'showSymbol'], + }, +}); diff --git a/chat2db-community-client/src/blocks/AI/agentContext.test.ts b/chat2db-community-client/src/blocks/AI/agentContext.test.ts new file mode 100644 index 0000000000..748d9e4219 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentContext.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { captureAgentContext, agentContextDatabaseType, agentContextSummary } from './agentContext'; +import { buildAgentTranscript } from './agentEvents'; +import type { AgentContextObject } from '@/types/agentContext'; +import type { AgentEvent } from '@/service/agent'; +import type { IBoundInfo } from '@/typings'; +import { DatabaseTypeCode } from '@/constants/common'; + +const selection: IBoundInfo = { dataSourceId: 123, dataSourceName: 'localhost', databaseType: DatabaseTypeCode.MYSQL, databaseName: 'sales', schemaName: 'public' }; +const table = { ...selection, tableName: 'orders' }; +const mention: AgentContextObject = { + dataSourceId: '123', dataSourceName: 'localhost', databaseType: 'MYSQL', database: 'sales', schema: 'public', + type: 'TABLE', name: 'orders', source: 'MENTION', +}; +const snapshot = captureAgentContext(selection, table, [mention], 'Asia/Shanghai'); +assert.deepEqual(snapshot.objects, [mention]); +selection.databaseName = 'other'; +mention.name = 'changed'; +assert.equal(snapshot.selection?.database, 'sales'); +assert.equal(snapshot.objects[0].name, 'orders'); +assert.deepEqual(captureAgentContext(selection, table, [mention], 'UTC').objects, []); +assert.deepEqual(captureAgentContext(null, table, [mention], 'UTC'), { timeZone: 'UTC', selection: null, objects: [] }); +assert.equal(captureAgentContext(table, table, [], 'UTC').objects[0].source, 'CURRENT_TABLE'); +assert.equal(captureAgentContext(table, { ...table, tableName: undefined, viewName: 'summary' }, [], 'UTC').objects[0].type, 'VIEW'); +const context = { ...snapshot, objects: snapshot.objects.map((item) => ({ ...item, dataSourceName: 'MySQL' })) }; +assert.equal(agentContextSummary(context), 'MySQL / sales / public / orders'); +assert.equal(snapshot.selection?.databaseType, 'MYSQL'); +assert.equal(agentContextDatabaseType(context), 'MYSQL'); +const event: AgentEvent = { id: 'one', sessionId: 'session', runId: 'run', sequence: 1, + type: 'RUN_ACCEPTED', payload: { text: '用户原文', context, renderedPrompt: 'internal' }, occurredAt: '' }; +const [message] = buildAgentTranscript([event]); +assert.equal(message.content, '用户原文'); +assert.equal(message.contextSummary, 'MySQL / sales / public / orders'); +assert.equal(buildAgentTranscript([{ ...event, payload: { text: 'legacy' } }])[0].content, 'legacy'); +console.log('Agent context snapshot, scope, references and history passed.'); diff --git a/chat2db-community-client/src/blocks/AI/agentContext.ts b/chat2db-community-client/src/blocks/AI/agentContext.ts new file mode 100644 index 0000000000..0d4ef099ab --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentContext.ts @@ -0,0 +1,69 @@ +import type { IBoundInfo } from '@/typings/workspace'; +import type { AgentContextScope, AgentContextObject, AgentRunContextRequest } from '@/types/agentContext'; + +type DatabaseSelection = Pick; + +export const contextScope = (selection?: DatabaseSelection | null): AgentContextScope | null => { + if (!selection?.dataSourceId) return null; + const scope: AgentContextScope = { + dataSourceId: String(selection.dataSourceId), + database: selection.databaseName || null, + schema: selection.schemaName || null, + }; + if (selection.dataSourceName) scope.dataSourceName = selection.dataSourceName; + if (selection.databaseType) scope.databaseType = String(selection.databaseType); + return scope; +}; + +const sameScope = (left: AgentContextScope, right: AgentContextScope) => + left.dataSourceId === right.dataSourceId && left.database === right.database && left.schema === right.schema; + +export const captureAgentContext = ( + selection: DatabaseSelection | null | undefined, + currentTable: IBoundInfo | undefined, + mentions: readonly AgentContextObject[], + timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone, +): AgentRunContextRequest => { + const scope = contextScope(selection); + const tableScope = contextScope(currentTable); + const objects = mentions.filter((object) => scope && sameScope(object, scope)).map((object) => ({ ...object })); + const name = currentTable?.viewName || currentTable?.tableName; + if (scope && tableScope && sameScope(scope, tableScope) && name) { + const type = currentTable?.viewName ? 'VIEW' : 'TABLE'; + if (!objects.some((object) => object.type === type && object.name === name)) { + objects.push({ ...scope, name, type, source: 'CURRENT_TABLE' }); + } + } + return { timeZone, selection: scope, objects }; +}; + +export const agentContextSummary = (context: unknown): string | undefined => { + if (!context || typeof context !== 'object') return undefined; + const { selection, objects } = context as Record; + const targets = Array.isArray(objects) && objects.length ? objects : selection ? [selection] : []; + const labels = targets.flatMap((target: unknown) => { + if (!target || typeof target !== 'object') return []; + const item = target as Record; + const name = typeof item.dataSourceName === 'string' ? item.dataSourceName : item.dataSourceId; + return [[name, item.database, item.schema, item.name].filter((part): part is string => + typeof part === 'string' && part.length > 0).join(' / ')]; + }); + return [...new Set(labels)].join(' · ') || undefined; +}; + +export const agentContextDatabaseType = (context: unknown): string | undefined => { + if (!context || typeof context !== 'object') return undefined; + const value = context as Record; + const selection = value.selection; + if (selection && typeof selection === 'object') { + const databaseType = (selection as Record).databaseType; + if (typeof databaseType === 'string' && databaseType) return databaseType; + } + const objects = value.objects; + if (Array.isArray(objects)) { + const object = objects.find((item) => item && typeof item === 'object'); + const databaseType = object && (object as Record).databaseType; + if (typeof databaseType === 'string' && databaseType) return databaseType; + } + return undefined; +}; diff --git a/chat2db-community-client/src/blocks/AI/agentEventStream.test.ts b/chat2db-community-client/src/blocks/AI/agentEventStream.test.ts new file mode 100644 index 0000000000..b7a5ce0055 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentEventStream.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import type { AgentEvent } from '@/service/agent'; +import { activeAgentRunId, followAgentRun, readAgentHistoryBefore, readAgentHistoryTail, + type ReadAgentEvents } from './agentEventStream'; +import { buildAgentTranscript, updateAgentApprovals } from './agentEvents'; + +const event = (sequence: number, type: AgentEvent['type'], runId = 'run'): AgentEvent => ({ + id: String(sequence), sessionId: 'session', runId, sequence, type, occurredAt: '', + payload: type === 'RUN_FAILED' ? { error: 'model connection failed' } : { text: 'hello' }, +}); + +async function main() { + const controller = new AbortController(); + let release: (events: AgentEvent[]) => void = () => {}; + const pending = new Promise((resolve) => { release = resolve; }); + const received: AgentEvent[] = []; + const stale = followAgentRun(() => pending, 'session', 'run', 0, controller.signal, + (events) => received.push(...events)); + controller.abort(); + release([event(1, 'ASSISTANT_TEXT_DELTA'), event(2, 'RUN_COMPLETED')]); + await stale; + assert.deepEqual(received, [], 'late response must not write into a different conversation'); + + const events = [event(1, 'RUN_COMPLETED', 'old'), event(2, 'ASSISTANT_TEXT_DELTA'), + event(2, 'ASSISTANT_TEXT_DELTA'), event(3, 'RUN_FAILED')]; + const delivered: AgentEvent[] = []; + const terminal = await followAgentRun(async () => events, 'session', 'run', 0, + new AbortController().signal, (page) => delivered.push(...page)); + assert.equal(terminal?.type, 'RUN_FAILED'); + assert.deepEqual(delivered.map((item) => item.sequence), [2, 3]); + const transcript = buildAgentTranscript(delivered); + assert.equal(transcript[0].traceEntries[0].content, 'model connection failed'); + assert.equal(transcript[0].status, 'failed'); + + /** Reads pages backwards, the way the server pages event files by sequence. */ + const countingRead = (source: AgentEvent[]) => { + const reads: number[] = []; + const read: ReadAgentEvents = async ({ beforeSequence = 0, limit }) => { + reads.push(beforeSequence); + return source.filter((item) => item.sequence < beforeSequence).slice(-limit); + }; + return { reads, read }; + }; + + // A long conversation opens on its newest events: one tail read, extended at most once to a turn start. + const history = Array.from({ length: 1205 }, (_, index) => event(index + 1, 'ASSISTANT_TEXT_DELTA')); + const tail = countingRead(history); + const loaded = await readAgentHistoryTail(tail.read, 'session', new AbortController().signal, 1205); + assert.deepEqual(tail.reads, [1206, 1006], 'the tail is read from the end, never from the start of the history'); + assert.equal(loaded.length, 400, 'alignment stops after one extra page even without a turn boundary'); + assert.equal(loaded[loaded.length - 1].sequence, 1205, 'the newest event stays in the window'); + + const completedHistory = [ + ...Array.from({ length: 300 }, (_, index) => event(index + 1, 'ASSISTANT_TEXT_DELTA')), + event(301, 'RUN_COMPLETED', 'old'), event(302, 'RUN_ACCEPTED', 'current'), + event(303, 'RUN_SUSPENDED', 'current'), + ]; + const restored = await readAgentHistoryTail(countingRead(completedHistory).read, 'session', + new AbortController().signal, 303); + assert.equal(restored[0].sequence, 302, 'a window cut inside a turn shrinks back to the turn start'); + assert.equal(restored[0].type, 'RUN_ACCEPTED'); + assert.equal(activeAgentRunId(restored), 'current', 'restore the newest unfinished run from the tail window'); + assert.equal(activeAgentRunId([...restored, event(304, 'RUN_OUTCOME_UNKNOWN', 'current')]), undefined, + 'a reconciled unknown outcome releases the run'); + + // Reading further back costs exactly one page per request and never doubles an event. + const pagedRead = countingRead(completedHistory); + const earlier = await readAgentHistoryBefore(pagedRead.read, 'session', new AbortController().signal, 302); + assert.deepEqual(pagedRead.reads, [302, 102], 'one page back, plus at most one alignment read'); + assert.deepEqual(earlier.map((item) => item.sequence), + Array.from({ length: 301 }, (_, index) => index + 1), + 'the page is contiguous, keeps every earlier event, and stops before the loaded window'); + + const openingRead = countingRead(history.slice(0, 120)); + const opening = await readAgentHistoryTail(openingRead.read, 'session', new AbortController().signal, 120); + assert.deepEqual(openingRead.reads, [121], 'a short history needs no alignment read'); + assert.equal(opening.length, 120, 'a window that reaches sequence 1 keeps the turn it has'); + + let startReads = 0; + const atStart = await readAgentHistoryBefore(async () => { startReads += 1; return []; }, 'session', + new AbortController().signal, 1); + assert.deepEqual(atStart, [], 'nothing is older than the first event'); + assert.equal(startReads, 0, 'the start of the history is not read again'); + + mock.timers.enable({ apis: ['setTimeout'] }); + try { + let attempts = 0; + const recoveredEvents: AgentEvent[] = []; + const approval = { ...event(1, 'APPROVAL_REQUESTED'), payload: { approvalId: 'approval', command: 'select 1' } }; + const recovery = followAgentRun(async (query) => { + attempts += 1; + assert.equal(query.sessionId, 'session', 'a transport may mutate its URL parameters without corrupting retries'); + delete (query as Partial).sessionId; + if (attempts === 1) return [approval]; + if (attempts === 2) throw new TypeError('Failed to fetch'); + assert.equal(query.afterSequence, 1, 'reconnection resumes from the last delivered event'); + return [approval, event(2, 'ASSISTANT_TEXT_DELTA'), event(3, 'RUN_COMPLETED')]; + }, 'session', 'run', 0, new AbortController().signal, (page) => recoveredEvents.push(...page)); + await setImmediate(); + mock.timers.tick(400); + await setImmediate(); + assert.equal(updateAgentApprovals([], recoveredEvents)[0].status, 'pending', + 'a connection failure must not close an approval or create a run terminal event'); + assert.equal(attempts, 2); + mock.timers.tick(1_000); + assert.equal((await recovery)?.type, 'RUN_COMPLETED'); + assert.deepEqual(recoveredEvents.map((item) => item.sequence), [1, 2, 3]); + + let timeoutAttempts = 0; + let latePage: (events: AgentEvent[]) => void = () => {}; + const timedOutEvents: AgentEvent[] = []; + const timedOut = followAgentRun(() => { + timeoutAttempts += 1; + return timeoutAttempts === 1 ? new Promise((resolve) => { latePage = resolve; }) + : Promise.resolve([event(1, 'RUN_COMPLETED')]); + }, 'session', 'run', 0, new AbortController().signal, (page) => timedOutEvents.push(...page)); + mock.timers.tick(15_000); + await setImmediate(); + mock.timers.tick(1_000); + assert.equal((await timedOut)?.type, 'RUN_COMPLETED', 'a hung request recovers after its deadline'); + latePage([event(2, 'ASSISTANT_TEXT_DELTA')]); + await setImmediate(); + assert.deepEqual(timedOutEvents.map((item) => item.sequence), [1], 'discard late data from the timed-out request'); + + const cancelled = new AbortController(); + let cancelledAttempts = 0; + const reconnecting = followAgentRun(async () => { + cancelledAttempts += 1; + throw new TypeError('offline'); + }, 'session', 'run', 0, cancelled.signal, () => assert.fail('cancelled observer must not deliver events')); + await setImmediate(); + cancelled.abort(); + await reconnecting; + mock.timers.tick(60_000); + assert.equal(cancelledAttempts, 1, 'leaving a session cancels pending retries'); + + let permanentAttempts = 0; + const permanent = followAgentRun(async () => { + permanentAttempts += 1; + throw Object.assign(new Error('business failure'), { errorCode: 'pi.invalidResponse' }); + }, 'session', 'run', 0, new AbortController().signal, () => assert.fail('a failed observer must not deliver events')); + await assert.rejects(permanent, /business failure/, 'a business error must reach the caller without retrying'); + assert.equal(permanentAttempts, 1); + + let boundedAttempts = 0; + const bounded = followAgentRun(async () => { + boundedAttempts += 1; + throw new TypeError('offline'); + }, 'session', 'run', 0, new AbortController().signal, () => assert.fail('a failed observer must not deliver events')); + const boundedFailure = bounded.then(() => undefined, (error: unknown) => error); + for (let tick = 0; tick < 12; tick += 1) { + mock.timers.tick(10_000); + await setImmediate(); + } + assert.match(String(await boundedFailure), /offline/, 'reconnects must stop once the attempt budget is spent'); + assert.equal(boundedAttempts, 9, 'one initial read plus the bounded reconnects'); + } finally { + mock.timers.reset(); + } +} + +void main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/chat2db-community-client/src/blocks/AI/agentEventStream.ts b/chat2db-community-client/src/blocks/AI/agentEventStream.ts new file mode 100644 index 0000000000..103b5b05b4 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentEventStream.ts @@ -0,0 +1,146 @@ +import type { AgentEvent } from '@/service/agent'; +import { isTerminalAgentEvent, mergeAgentEvents } from './agentEvents'; + +type EventQuery = { sessionId: string; afterSequence?: number; beforeSequence?: number; limit: number }; +export type ReadAgentEvents = (query: EventQuery, options: { signal: AbortSignal }) => Promise; +const PAGE_SIZE = 200; +const READ_TIMEOUT_MS = 15_000; +const MAX_RETRY_DELAY_MS = 10_000; +const MAX_RECONNECT_ATTEMPTS = 8; + +/** A business error repeats identically, so only transport-shaped failures are worth retrying. */ +const isPermanentReadError = (error: unknown) => + typeof error === 'object' && error !== null && Boolean((error as { errorCode?: unknown }).errorCode); + +export const traceAgentStage = (stage: string, fields: Record) => { + console.debug('[AgentTrace] ' + JSON.stringify({ stage, ...fields })); +}; + +export const activeAgentRunId = (events: AgentEvent[]) => { + const accepted = [...events].reverse().find((event) => event.type === 'RUN_ACCEPTED'); + return accepted?.runId && !events.some((event) => + event.runId === accepted.runId && isTerminalAgentEvent(event)) ? accepted.runId : undefined; +}; + +async function readEventPage(read: ReadAgentEvents, query: EventQuery, signal: AbortSignal, reconnect = false) { + let retryDelay = 1_000; + let attempt = 0; + while (!signal.aborted) { + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('Agent event request timed out')), READ_TIMEOUT_MS); + let rejectAborted: () => void = () => {}; + try { + const aborted = new Promise((_, reject) => { + rejectAborted = () => reject(controller.signal.reason); + controller.signal.addEventListener('abort', rejectAborted, { once: true }); + }); + // The desktop bridge may settle late even after abort. Race the deadline + // so observation can recover without treating transport loss as run failure. + return await Promise.race([read({ ...query }, { signal: controller.signal }), aborted]); + } catch (error) { + if (signal.aborted) return []; + // Bounded reconnects: a failure that never heals must reach the caller so the run can end visibly. + if (!reconnect || ++attempt > MAX_RECONNECT_ATTEMPTS || isPermanentReadError(error)) throw error; + traceAgentStage('events.reconnecting', { sessionId: query.sessionId, afterSequence: query.afterSequence, retryDelay, attempt }); + } finally { + clearTimeout(timer); + signal.removeEventListener('abort', abort); + controller.signal.removeEventListener('abort', rejectAborted); + } + await waitForPoll(signal, retryDelay); + retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS); + } + return []; +} + +const MAX_ALIGN_PAGES = 1; + +/** + * The newest events of a session. Event files are named by sequence, so the server can read this page + * straight from the tail instead of scanning the whole history. A long conversation therefore opens + * on its last turns instead of replaying every streamed delta. + */ +export async function readAgentHistoryTail( + read: ReadAgentEvents, sessionId: string, signal: AbortSignal, lastEventSequence: number) { + if (lastEventSequence < 1) return []; + const page = await readEventPage(read, + { sessionId, beforeSequence: lastEventSequence + 1, limit: PAGE_SIZE }, signal); + return alignPageToRunStart(read, sessionId, signal, page); +} + +/** The page of events just before {@code beforeSequence}, aligned to a turn boundary. */ +export async function readAgentHistoryBefore( + read: ReadAgentEvents, sessionId: string, signal: AbortSignal, beforeSequence: number) { + if (beforeSequence < 2) return []; + const page = await readEventPage(read, { sessionId, beforeSequence, limit: PAGE_SIZE }, signal); + return alignPageToRunStart(read, sessionId, signal, page); +} + +/** Extends a cut-off page backwards until it starts where a turn starts, or the history does. */ +async function alignPageToRunStart( + read: ReadAgentEvents, sessionId: string, signal: AbortSignal, page: AgentEvent[]) { + let loaded = page.filter((event) => event.sessionId === sessionId); + for (let attempt = 0; attempt < MAX_ALIGN_PAGES; attempt += 1) { + if (signal.aborted || loaded.length < PAGE_SIZE || loaded[0].sequence <= 1 + || loaded[0].type === 'RUN_ACCEPTED') break; + const older = (await readEventPage(read, { + sessionId, beforeSequence: loaded[0].sequence, limit: PAGE_SIZE }, signal)) + .filter((event) => event.sessionId === sessionId && event.sequence < loaded[0].sequence); + signal.throwIfAborted(); + if (older.length === 0) break; + loaded = mergeAgentEvents(older, loaded); + } + const boundary = loaded.findIndex((event) => event.type === 'RUN_ACCEPTED'); + traceAgentStage('history.page', { + sessionId, beforeSequence: page[0]?.sequence ?? 0, received: page.length, total: loaded.length, + }); + // A full page that starts inside a turn was cut off by the window, so drop that half turn. A short + // page reached sequence 1: it is the real start of the history and keeps the turn it has. + return boundary > 0 && loaded.length >= PAGE_SIZE ? loaded.slice(boundary) : loaded; +} + +export async function followAgentRun( + read: ReadAgentEvents, + sessionId: string, + runId: string, + afterSequence: number, + signal: AbortSignal, + onEvents: (events: AgentEvent[]) => void, +) { + let sequence = afterSequence; + while (!signal.aborted) { + traceAgentStage('events.request', { sessionId, runId, afterSequence: sequence }); + const page = await readEventPage(read, { sessionId, afterSequence: sequence, limit: PAGE_SIZE }, signal, true); + if (signal.aborted) { + traceAgentStage('events.discarded', { sessionId, runId, received: page.length }); + return; + } + const incoming = mergeAgentEvents([], page).filter( + (event) => event.sessionId === sessionId && event.sequence > sequence, + ); + if (incoming.length) sequence = incoming[incoming.length - 1].sequence; + const events = incoming.filter((event) => event.runId === runId); + traceAgentStage('events.received', { + sessionId, runId, sequence, events: events.map((event) => ({ sequence: event.sequence, type: event.type })), + }); + if (events.length) onEvents(events); + const terminal = events.find(isTerminalAgentEvent); + if (terminal) return terminal; + if (page.length < PAGE_SIZE) await waitForPoll(signal); + } +} + +function waitForPoll(signal: AbortSignal, delay = 400) { + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, delay); + signal.addEventListener('abort', finish, { once: true }); + if (signal.aborted) finish(); + }); +} diff --git a/chat2db-community-client/src/blocks/AI/agentEvents.test.ts b/chat2db-community-client/src/blocks/AI/agentEvents.test.ts new file mode 100644 index 0000000000..b695a5a2e9 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentEvents.test.ts @@ -0,0 +1,133 @@ +import koApprovals from '@/i18n/ko-KR/stream'; +import jaApprovals from '@/i18n/ja-JP/stream'; +import esApprovals from '@/i18n/es-ES/stream'; +import enApprovals from '@/i18n/en-US/stream'; +import zhApprovals from '@/i18n/zh-CN/stream'; +import assert from 'node:assert/strict'; +import { appendAgentTimeline, agentEventTrace, buildAgentTranscript, isTerminalAgentEvent, mergeAgentEvents, updateAgentApprovals } from './agentEvents'; +import type { AgentEvent } from '@/service/agent'; + +const event = (sequence: number, type: AgentEvent['type'], payload: Record = {}): AgentEvent => ({ + id: `event-${sequence}`, + sessionId: 'session', + runId: 'run', + sequence, + type, + payload, + occurredAt: '', +}); + +const merged = mergeAgentEvents( + [event(1, 'RUN_ACCEPTED', { text: 'hello' }), event(2, 'ASSISTANT_MESSAGE_STARTED')], + [ + event(2, 'ASSISTANT_MESSAGE_STARTED'), + event(3, 'ASSISTANT_TEXT_DELTA', { assistantMessageEvent: { type: 'text_delta', delta: 'hi' } }), + ], +); +assert.deepEqual(merged.map((item) => item.sequence), [1, 2, 3]); +assert.deepEqual(buildAgentTranscript(merged), [ + { id: 'user-event-1', runId: 'run', role: 'user', content: 'hello', traceEntries: [] }, + { id: 'assistant-run', runId: 'run', role: 'assistant', content: 'hi', traceEntries: [], + timeline: [{ kind: 'text', sequence: 3, text: 'hi' }] }, +]); +const recoveredTranscript = buildAgentTranscript([ + event(1, 'RUN_ACCEPTED', { text: 'old run' }), + event(2, 'RUN_OUTCOME_UNKNOWN'), + event(3, 'RUN_COMPLETED'), +]); +assert.equal(recoveredTranscript[1].status, undefined); +assert.equal(isTerminalAgentEvent(event(4, 'RUN_SUSPENDED')), false); +assert.equal(isTerminalAgentEvent(event(5, 'RUN_COMPLETED')), true); + +const requested = event(4, 'APPROVAL_REQUESTED', { + approvalId: 'approval-1', toolName: 'bash', command: "printf 'line 1\\nline 2'", workingDirectory: '/folder with spaces', +}); +const toolResultTrace = agentEventTrace(event(6, 'TOOL_CALL_COMPLETED', { + toolCallId: 'duration-call', toolName: 'db_query', durationMs: 17, result: { details: { data: { durationMs: 999 } } }, +})); +assert.equal(toolResultTrace?.durationMs, 17); +const requestedAgain = event(5, 'APPROVAL_REQUESTED', requested.payload); +const sqlApproval = event(5, 'APPROVAL_REQUESTED', { + approvalId: 'sql-approval', toolName: 'db_query', command: 'SELECT 1; UPDATE sales SET amount=2;', + dataSourceId: '7', dataSourceName: 'Local MySQL', database: 'app', schema: 'tenant', +}); +const sqlPending = updateAgentApprovals([], [sqlApproval]); +assert.equal(sqlPending[0].toolName, 'SQL'); +assert.equal(sqlPending[0].command, sqlApproval.payload.command); +assert.deepEqual(sqlPending[0].databaseTarget, { + dataSourceId: '7', dataSourceName: 'Local MySQL', database: 'app', schema: 'tenant', +}); +assert.equal(updateAgentApprovals(sqlPending, [event(6, 'APPROVAL_DECIDED', { + approvalId: 'sql-approval', approved: false, +})])[0].status, 'denied'); +const pendingApprovals = updateAgentApprovals([], [requested, requestedAgain]); +assert.equal(pendingApprovals.length, 1); +assert.equal(pendingApprovals[0].command, requested.payload.command); +assert.equal(pendingApprovals[0].status, 'pending'); +const approved = updateAgentApprovals(pendingApprovals, [event(6, 'APPROVAL_DECIDED', { approvalId: 'approval-1', approved: true })]); +assert.equal(approved[0].status, 'approved'); +assert.equal(updateAgentApprovals(approved, [requestedAgain])[0].status, 'approved'); +const second = event(7, 'APPROVAL_REQUESTED', { ...requested.payload, approvalId: 'approval-2' }); +const parallel = updateAgentApprovals(approved, [second, event(8, 'APPROVAL_DECIDED', { approvalId: 'approval-2', approved: false })]); +assert.deepEqual(parallel.map((item) => item.status), ['approved', 'denied']); +const nextRun = { ...second, runId: 'next-run', payload: { ...second.payload, approvalId: 'approval-3' } }; +const terminal = updateAgentApprovals(pendingApprovals, [nextRun, event(9, 'RUN_CANCELLED')]); +assert.deepEqual(terminal.map((item) => item.status), ['closed', 'pending']); +assert.deepEqual(updateAgentApprovals([], [requested, event(10, 'APPROVAL_DECIDED', { approvalId: 'approval-1', approved: true })]), approved); +assert.deepEqual(updateAgentApprovals([], [event(4, 'APPROVAL_REQUESTED', { approvalId: 'missing-command' })]), []); + +for (const locale of [zhApprovals, enApprovals, esApprovals, jaApprovals, koApprovals]) { + for (const status of ['pending', 'approved', 'denied', 'closed'] as const) { + assert.ok(locale[`stream.approval.${status}`]); + } + assert.ok(locale['stream.approval.approve']); + assert.ok(locale['stream.approval.deny']); + for (const key of ['datasource', 'database', 'schema']) assert.ok(locale[`stream.approval.${key}`]); +} +assert.notEqual(zhApprovals['stream.approval.pending'], enApprovals['stream.approval.pending']); + +const timelineEvents = [ + event(1, 'RUN_ACCEPTED', { text: 'check attendance' }), + event(2, 'ASSISTANT_TEXT_DELTA', { text: 'Which ' }), + event(3, 'ASSISTANT_TEXT_DELTA', { text: 'database?' }), + event(4, 'QUESTION_REQUESTED', { questionId: 'question-1' }), + event(5, 'QUESTION_ANSWERED', { questionId: 'question-1', text: 'app' }), + event(6, 'TOOL_CALL_RUNNING', { toolCallId: 'query-1', toolName: 'db_query', args: { description: '查询订单数据', sql: 'SELECT 1' } }), + event(7, 'TOOL_CALL_COMPLETED', { toolCallId: 'query-1', toolName: 'db_query', result: { rows: [[1]] } }), + event(8, 'CHART_CREATED', { chart: { id: 'chart-1' } }), + event(9, 'TOOL_CALL_COMPLETED', { toolCallId: 'chart-tool', toolName: 'render_chart', + result: { details: { data: { chartId: 'chart-1' } } } }), + event(10, 'ASSISTANT_TEXT_DELTA', { text: 'Done.' }), + event(11, 'ASSISTANT_MESSAGE_STARTED'), + event(12, 'ASSISTANT_TEXT_DELTA', { text: 'Next paragraph.' }), + event(13, 'RUN_COMPLETED'), +]; +const firstChunk = appendAgentTimeline([], timelineEvents.slice(0, 2)); +Object.freeze(firstChunk[0]); +const secondChunk = appendAgentTimeline(firstChunk, timelineEvents.slice(2, 5)); +assert.deepEqual(firstChunk, [{ kind: 'text', sequence: 2, text: 'Which ' }]); +assert.deepEqual(secondChunk.map((entry) => [entry.sequence, entry.kind]), [[2, 'text'], [4, 'question']]); +const liveTimeline = appendAgentTimeline(secondChunk, timelineEvents.slice(5)); +const replayTimeline = buildAgentTranscript([...timelineEvents].reverse())[1].timeline; +assert.deepEqual(liveTimeline, replayTimeline); +assert.deepEqual(liveTimeline.map((entry) => [entry.sequence, entry.kind]), [ + [2, 'text'], [4, 'question'], [6, 'trace'], [7, 'trace'], [8, 'chart'], [9, 'trace'], [10, 'text'], +]); +assert.deepEqual(appendAgentTimeline(liveTimeline, timelineEvents.slice(8)), liveTimeline); +const finalText = liveTimeline.at(-1); +assert.equal(finalText?.kind === 'text' && finalText.text, 'Done.\n\nNext paragraph.'); +assert.equal(liveTimeline[5].kind === 'trace' && liveTimeline[5].trace.chartId, 'chart-1'); +const reasoning = appendAgentTimeline([], [event(1, 'ASSISTANT_REASONING_DELTA', { text: 'Think ' }), + event(2, 'ASSISTANT_REASONING_DELTA', { text: 'carefully' })]); +assert.equal(reasoning.length, 1); +assert.equal(reasoning[0].kind === 'trace' && reasoning[0].trace.content, 'Think carefully'); +assert.deepEqual(buildAgentTranscript([{ ...event(1, 'ASSISTANT_MESSAGE_STARTED'), runId: undefined }]), []); +for (const locale of [zhApprovals, enApprovals, esApprovals, jaApprovals, koApprovals]) { + for (const key of ['stream.question.prompt', 'stream.question.answer', 'stream.directory.clear']) assert.ok(locale[key]); +} + +const timedHistory = appendAgentTimeline([], [ + { ...event(1, 'TOOL_CALL_RUNNING', { toolCallId: 'timed', toolName: 'db_query' }), occurredAt: '2026-09-12T00:00:00.010Z' }, + { ...event(2, 'TOOL_CALL_COMPLETED', { toolCallId: 'timed', result: {} }), occurredAt: '2026-09-12T00:00:00.035Z' }, +]); +assert.equal(timedHistory[1].kind === 'trace' && timedHistory[1].trace.durationMs, 25); diff --git a/chat2db-community-client/src/blocks/AI/agentEvents.ts b/chat2db-community-client/src/blocks/AI/agentEvents.ts new file mode 100644 index 0000000000..00a22f2086 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentEvents.ts @@ -0,0 +1,283 @@ +import type { AgentEvent } from '@/service/agent'; +import type { AgentOutputItem } from '@/types/agentOutput'; +import { toolOutputItems } from './agentOutput'; +import { agentContextDatabaseType, agentContextSummary } from './agentContext'; + +/** What the user answered on an approval card. */ +export type AgentApprovalDecision = 'ALLOW_ONCE' | 'ALLOW_TOOL' | 'ALLOW_SERVER' | 'DENY'; + +export interface AgentApprovalItem { + id: string; + sessionId: string; + runId: string; + toolName: string; + command: string; + workingDirectory: string; + databaseTarget?: { dataSourceId: string; dataSourceName: string; database?: string; schema?: string }; + status: 'pending' | 'approved' | 'denied' | 'closed'; +} + +/** The label an approval card shows for the tool that asked for permission. */ +export const approvalToolLabel = (toolName: unknown): string => { + if (typeof toolName !== 'string' || !toolName) return 'Bash'; + if (toolName === 'db_query') return 'SQL'; + if (toolName === 'powershell') return 'PowerShell'; + if (toolName === 'bash') return 'Bash'; + return toolName; +}; + +export const updateAgentApprovals = ( + current: AgentApprovalItem[], events: AgentEvent[], +): AgentApprovalItem[] => { + const approvals = new Map(current.map((item) => [item.id, item])); + for (const event of events) { + const { + approvalId, command, workingDirectory, toolName, approved, dataSourceId, dataSourceName, database, schema, + } = event.payload; + if (event.type === 'APPROVAL_REQUESTED' && typeof approvalId === 'string' + && typeof command === 'string' && event.runId && !approvals.has(approvalId)) { + approvals.set(approvalId, { + id: approvalId, sessionId: event.sessionId, runId: event.runId, command, + workingDirectory: typeof workingDirectory === 'string' ? workingDirectory : '', + // Only the known shell and SQL tools get a friendly label; anything else (an MCP tool, + // for example) keeps its own name so the card says what it will actually do. + toolName: approvalToolLabel(toolName), + ...(toolName === 'db_query' && typeof dataSourceId === 'string' ? { databaseTarget: { + dataSourceId, + dataSourceName: typeof dataSourceName === 'string' ? dataSourceName : dataSourceId, + database: typeof database === 'string' ? database : undefined, + schema: typeof schema === 'string' ? schema : undefined, + } } : {}), + status: 'pending', + }); + } + if (event.type === 'APPROVAL_DECIDED' && typeof approvalId === 'string') { + const item = approvals.get(approvalId); + if (item && item.sessionId === event.sessionId && item.runId === event.runId) { + approvals.set(approvalId, { ...item, status: approved === true ? 'approved' : 'denied' }); + } + } + if (isTerminalAgentEvent(event)) { + for (const [id, item] of approvals) { + if (item.sessionId === event.sessionId && item.runId === event.runId && item.status === 'pending') { + approvals.set(id, { ...item, status: 'closed' }); + } + } + } + } + return [...approvals.values()]; +}; + +export interface AgentTranscriptMessage { + contextSummary?: string; + contextDatabaseType?: string; + id: string; + runId: string; + role: 'user' | 'assistant'; + content: string; + status?: 'failed' | 'unknown' | 'cancelled'; + traceEntries: AgentTraceEntry[]; + timeline?: AgentTimelineEntry[]; +} + +export interface AgentTraceEntry { + type: 'reasoning' | 'tool_call' | 'tool_result' | 'error'; + content?: string; + name?: string; + arguments?: string; + id?: string; + chartId?: string; + failed?: boolean; + description?: string; + durationMs?: number; + outputs?: AgentOutputItem[]; + occurredAtMs?: number; +} + +export type AgentTimelineEntry = { sequence: number; endSequence?: number } & ( + | { kind: 'text'; text: string } + | { kind: 'trace'; trace: AgentTraceEntry } + | { kind: 'question' | 'approval' | 'chart'; id: string } +); + +export const agentEventText = (payload: Record) => { + for (const key of ['content', 'text', 'delta']) { + const value = payload[key]; + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + const nested = value as Record; + if (typeof nested.text === 'string') return nested.text; + if (typeof nested.content === 'string') return nested.content; + } + } + const assistantEvent = payload.assistantMessageEvent; + if (assistantEvent && typeof assistantEvent === 'object') { + const delta = (assistantEvent as Record).delta; + if (typeof delta === 'string') return delta; + } + return ''; +}; + +export const mergeAgentEvents = (current: AgentEvent[], incoming: AgentEvent[]) => { + const events = new Map(); + [...current, ...incoming].forEach((event) => events.set(event.sequence, event)); + return [...events.values()].sort((left, right) => left.sequence - right.sequence); +}; + +export const appendAgentText = (current: string, events: AgentEvent[]) => + events.reduce((text, event) => { + if (event.type === 'ASSISTANT_MESSAGE_STARTED' && text && !text.endsWith('\n\n')) return text + '\n\n'; + return event.type === 'ASSISTANT_TEXT_DELTA' ? text + agentEventText(event.payload) : text; + }, current); + +export const appendAgentTimeline = (current: AgentTimelineEntry[], events: AgentEvent[]) => { + const timeline = [...current]; + mergeAgentEvents([], events).forEach((event) => { + const last = timeline[timeline.length - 1]; + if (last && event.sequence <= (last.endSequence || last.sequence)) return; + if (event.type === 'ASSISTANT_MESSAGE_STARTED') { + if (last?.kind === 'text' && !last.text.endsWith('\n\n')) { + timeline[timeline.length - 1] = { ...last, text: last.text + '\n\n', endSequence: event.sequence }; + } + return; + } + if (event.type === 'ASSISTANT_TEXT_DELTA') { + const text = agentEventText(event.payload); + if (!text) return; + if (last?.kind === 'text') { + timeline[timeline.length - 1] = { ...last, text: last.text + text, endSequence: event.sequence }; + } else { + timeline.push({ kind: 'text', sequence: event.sequence, text }); + } + return; + } + if (event.type === 'QUESTION_REQUESTED' && typeof event.payload.questionId === 'string') { + timeline.push({ kind: 'question', sequence: event.sequence, id: event.payload.questionId }); + return; + } + if (event.type === 'APPROVAL_REQUESTED' && typeof event.payload.approvalId === 'string') { + timeline.push({ kind: 'approval', sequence: event.sequence, id: event.payload.approvalId }); + return; + } + if (event.type === 'CHART_CREATED' && event.payload.chart && typeof event.payload.chart === 'object' + && typeof (event.payload.chart as Record).id === 'string') { + timeline.push({ kind: 'chart', sequence: event.sequence, id: (event.payload.chart as Record).id as string }); + return; + } + const trace = agentEventTrace(event); + if (trace?.type === 'tool_result' && trace.durationMs === undefined && trace.occurredAtMs !== undefined) { + const start = timeline.find((entry) => entry.kind === 'trace' && entry.trace.type === 'tool_call' + && entry.trace.id === trace.id); + if (start?.kind === 'trace' && start.trace.occurredAtMs !== undefined) { + trace.durationMs = Math.max(0, Math.round(trace.occurredAtMs - start.trace.occurredAtMs)); + } + } + if (trace?.type === 'reasoning' && last?.kind === 'trace' && last.trace.type === 'reasoning') { + timeline[timeline.length - 1] = { ...last, endSequence: event.sequence, + trace: { ...last.trace, content: (last.trace.content || '') + (trace.content || '') } }; + } else if (trace) timeline.push({ kind: 'trace', sequence: event.sequence, trace }); + }); + return timeline; +}; + +export const buildAgentTranscript = (events: AgentEvent[]): AgentTranscriptMessage[] => { + const messages: AgentTranscriptMessage[] = []; + const assistants = new Map(); + mergeAgentEvents([], events).forEach((event) => { + const runId = event.runId; + if (!runId) return; + if (event.type === 'RUN_ACCEPTED') { + const text = typeof event.payload.text === 'string' ? event.payload.text : ''; + const contextSummary = agentContextSummary(event.payload.context); + const contextDatabaseType = agentContextDatabaseType(event.payload.context); + if (text) messages.push({ id: `user-${event.id}`, runId, role: 'user', content: text, traceEntries: [], + ...(contextSummary ? { contextSummary } : {}), + ...(contextDatabaseType ? { contextDatabaseType } : {}) }); + return; + } + { + let assistant = assistants.get(runId); + if (!assistant) { + assistant = { id: `assistant-${runId}`, runId, role: 'assistant', content: '', traceEntries: [] }; + assistants.set(runId, assistant); + messages.push(assistant); + } + assistant.content = appendAgentText(assistant.content, [event]); + const trace = agentEventTrace(event); + if (trace) assistant.traceEntries.push(trace); + const timeline = appendAgentTimeline(assistant.timeline || [], [event]); + if (timeline.length) assistant.timeline = timeline; + } + const assistant = assistants.get(runId); + if (!assistant) return; + if (event.type === 'RUN_FAILED') assistant.status = 'failed'; + if (event.type === 'RUN_OUTCOME_UNKNOWN') assistant.status = 'unknown'; + if (event.type === 'RUN_CANCELLED') assistant.status = 'cancelled'; + if (event.type === 'RUN_COMPLETED' || event.type === 'RUN_SUSPENDED') delete assistant.status; + }); + return messages; +}; + +export const isTerminalAgentEvent = (event: AgentEvent) => + // SUSPENDED is a resumable state (for questions/approvals), not a terminal + // run. Keep polling so an answer can continue the same operation. + ['RUN_COMPLETED', 'RUN_FAILED', 'RUN_CANCELLED', 'RUN_OUTCOME_UNKNOWN'].includes(event.type); + +export const agentErrorText = (error: unknown): string => { + if (typeof error === 'string') return error; + if (!error || typeof error !== 'object') return ''; + const value = error as Record; + for (const key of ['errorMessage', 'message', 'error', 'reason']) { + const text = agentErrorText(value[key]); + if (text) return text; + } + return ''; +}; + +const eventTime = (value: unknown): number | undefined => { + // Jackson can encode LocalDateTime as an array on older servers. + const time = Array.isArray(value) && value.length >= 6 && value.every((part) => typeof part === 'number') + ? Date.UTC(value[0], value[1] - 1, value[2], value[3], value[4], value[5], (value[6] || 0) / 1e6) + : typeof value === 'string' ? Date.parse(value) : NaN; + return Number.isFinite(time) ? time : undefined; +}; + +export const agentEventTrace = (event: AgentEvent): AgentTraceEntry | undefined => { + const payload = event.payload; + if (event.type === 'ASSISTANT_REASONING_DELTA') { + return { type: 'reasoning', content: agentEventText(payload) }; + } + if (event.type === 'RUN_FAILED' || event.type === 'RUN_OUTCOME_UNKNOWN') { + return { type: 'error', content: agentErrorText(payload) || event.type }; + } + const occurredAtMs = eventTime(event.occurredAt); + const name = typeof payload.toolName === 'string' ? payload.toolName : undefined; + const id = typeof payload.toolCallId === 'string' ? payload.toolCallId : event.id; + const description = typeof payload.description === 'string' ? payload.description + : payload.args && typeof payload.args === 'object' && typeof (payload.args as Record).description === 'string' + ? (payload.args as Record).description as string : undefined; + if (event.type === 'TOOL_CALL_RUNNING') { + return { type: 'tool_call', id, name, description, ...(occurredAtMs === undefined ? {} : { occurredAtMs }), + arguments: JSON.stringify(payload.args || {}) }; + } + if (event.type === 'TOOL_CALL_COMPLETED' || event.type === 'TOOL_CALL_FAILED') { + const result = payload.result as { content?: { type: string; text?: string }[]; + details?: { data?: { chartId?: unknown }; durationMs?: unknown; output?: unknown }; + durationMs?: unknown } | undefined; + const content = Array.isArray(result?.content) + ? result.content.filter((item) => item.type === 'text').map((item) => item.text || '') +.join('\n') + : JSON.stringify(payload.result || payload); + const chartId = result?.details?.data?.chartId; + const outputs = toolOutputItems(content, result?.details); + const durationCandidates = [payload.durationMs, result?.durationMs, result?.details?.durationMs]; + const durationMs = durationCandidates.find((value): value is number => typeof value === 'number' + && Number.isFinite(value) && value >= 0); + return { type: 'tool_result', id, name, content, ...(occurredAtMs === undefined ? {} : { occurredAtMs }), + ...(durationMs === undefined ? {} : { durationMs }), + ...(outputs.length ? { outputs } : {}), + ...(event.type === 'TOOL_CALL_FAILED' ? { failed: true } : {}), + ...(name === 'render_chart' && typeof chartId === 'string' ? { chartId } : {}) }; + } + return undefined; +}; diff --git a/chat2db-community-client/src/blocks/AI/agentOutput.test.ts b/chat2db-community-client/src/blocks/AI/agentOutput.test.ts new file mode 100644 index 0000000000..640982c1e1 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentOutput.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { agentEventTrace, buildAgentTranscript } from './agentEvents'; +import { formatToolResult, parseOutputReference, toolOutputItems } from './agentOutput'; +import { toolExecutions, toolSummary } from './components/AgentV2Session/presentation'; +import type { AgentEvent } from '@/service/agent'; +import zh from '@/i18n/zh-CN/stream'; +import en from '@/i18n/en-US/stream'; +import ja from '@/i18n/ja-JP/stream'; +import ko from '@/i18n/ko-KR/stream'; +import es from '@/i18n/es-ES/stream'; + +const output = { mode: 'file', artifactId: 'out-1', path: '/system/results/out-1.jsonl', + format: 'jsonl', sizeBytes: 900_000, complete: true, previewTruncated: true } as const; +const event = (sequence: number, type: AgentEvent['type'], payload: AgentEvent['payload']): AgentEvent => ({ + id: `event-${sequence}`, sessionId: 'session', runId: 'run', sequence, type, payload, occurredAt: '', +}); +const started = event(2, 'TOOL_CALL_RUNNING', { + toolCallId: 'call', toolName: 'db_query', args: { description: 'Load rows', sql: 'select * from sample' }, +}); +const finished = event(3, 'TOOL_CALL_COMPLETED', { + toolCallId: 'call', toolName: 'db_query', durationMs: 25, + result: { content: [{ type: 'text', text: JSON.stringify({ ok: true, data: { rows: [[1, null]] }, output }) }], + details: { output } }, +}); +const trace = agentEventTrace(finished)!; +assert.deepEqual(trace.outputs, [{ output }]); +const contentOnly = structuredClone(finished); +delete (contentOnly.payload.result as Record).details; +assert.deepEqual(agentEventTrace(contentOnly)?.outputs, [{ output }], 'Replayed content envelopes retain their file reference'); +assert.deepEqual(JSON.parse(formatToolResult(trace.content!)), { ok: true, data: { rows: [[1, null]] }, output }, + 'Displayed tool results must retain the file reference received by the model'); +const tools = toolExecutions([agentEventTrace(started)!, trace]); +assert.equal(tools.length, 1); +assert.equal(tools[0].description, 'Load rows'); +assert.deepEqual(tools[0].outputs, [{ output }]); +assert.deepEqual(toolSummary([agentEventTrace(started)!, trace]), { count: 1, durationMs: 25 }); +const history = buildAgentTranscript([event(1, 'RUN_ACCEPTED', { text: 'query rows' }), started, finished, + event(4, 'RUN_COMPLETED', {})]); +assert.deepEqual(history[1].traceEntries.find((entry) => entry.type === 'tool_result')?.outputs, [{ output }]); + +const multiple = { ok: true, data: { results: [ + { columns: ['small'], rows: [[1]] }, + { columns: ['large'], rows: [['preview']], output }, + { columns: ['other'], rows: [['preview']], output: { ...output, artifactId: 'out-2' } }, +] } }; +assert.deepEqual(toolOutputItems(JSON.stringify(multiple)), [ + { output, resultIndex: 2 }, { output: { ...output, artifactId: 'out-2' }, resultIndex: 3 }, +]); +const multipleFinished = structuredClone(finished); +multipleFinished.payload.result = { + content: [{ type: 'text', text: JSON.stringify(multiple) }], details: multiple, +}; +const multipleTrace = agentEventTrace(multipleFinished)!; +assert.equal(multipleTrace.outputs?.length, 2, 'Content and details references must not duplicate attachments'); +assert.deepEqual(toolSummary([agentEventTrace(started)!, multipleTrace]), { count: 1, durationMs: 25 }); +assert.deepEqual(JSON.parse(formatToolResult(JSON.stringify(multiple))), multiple, + 'Each statement keeps its own output reference in the displayed JSON'); + +const paged = { ok: true, page: { number: 2, size: 200, returned: 200, hasMore: true, nextPage: 3 }, + data: { results: [{ data: { columns: ['id', 'message'], rows: [['1', 'preview'], ['2']] }, output }] }, output }; +assert.deepEqual(JSON.parse(formatToolResult(JSON.stringify(paged))), paged, + 'Preview truncation, file completeness and SQL pagination must remain visible together'); + +assert.equal(parseOutputReference({ ...output, sizeBytes: -1 }), undefined); +assert.equal(parseOutputReference({ ...output, artifactId: '' }), undefined); +assert.equal(parseOutputReference({ path: 'user supplied path' }), undefined); +assert.deepEqual(parseOutputReference({ mode: 'unavailable', warning: 'Disk full' }), { + mode: 'unavailable', warning: 'Disk full', complete: false, previewTruncated: true, +}); +assert.equal(formatToolResult('ordinary tool text'), 'ordinary tool text'); +assert.deepEqual(JSON.parse(formatToolResult('{"output":"business value"}')), { output: 'business value' }); + +let messages: Record = zh; +const translate = (key: string) => messages[key] || key; +assert.equal(translate('stream.output.view'), '查看内容'); +messages = en; +assert.equal(translate('stream.output.view'), 'View content'); +for (const locale of [zh, en, ja, ko, es]) { + for (const key of Object.keys(zh).filter((candidate) => candidate.startsWith('stream.output.'))) assert.ok(locale[key]); +} +console.log('Output presentation tests passed: preview, history, chronology, invalid references and locales'); diff --git a/chat2db-community-client/src/blocks/AI/agentOutput.ts b/chat2db-community-client/src/blocks/AI/agentOutput.ts new file mode 100644 index 0000000000..b953ecef34 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentOutput.ts @@ -0,0 +1,42 @@ +import type { AgentOutputItem, AgentOutputReference } from '@/types/agentOutput'; + +export const parseOutputReference = (value: unknown): AgentOutputReference | undefined => { + if (!value || typeof value !== 'object') return undefined; + const output = value as Record; + const warning = typeof output.warning === 'string' ? { warning: output.warning } : {}; + if (output.mode === 'unavailable') return { + mode: 'unavailable', complete: false, previewTruncated: true, ...warning, + }; + if (output.mode !== 'file' || typeof output.artifactId !== 'string' || !output.artifactId + || typeof output.path !== 'string' || typeof output.format !== 'string' + || typeof output.sizeBytes !== 'number' || !Number.isFinite(output.sizeBytes) || output.sizeBytes < 0) return undefined; + return { + mode: 'file', artifactId: output.artifactId, path: output.path, format: output.format, + sizeBytes: output.sizeBytes, complete: output.complete === true, + previewTruncated: output.previewTruncated === true, ...warning, + }; +}; + +const envelopeOutputs = (value: unknown): AgentOutputItem[] => { + if (!value || typeof value !== 'object') return []; + const envelope = value as { output?: unknown; data?: { results?: { output?: unknown }[] } }; + const output = parseOutputReference(envelope.output); + if (output) return [{ output }]; + if (!Array.isArray(envelope.data?.results)) return []; + return envelope.data.results.flatMap((result, index) => { + const reference = parseOutputReference(result?.output); + return reference ? [{ output: reference, resultIndex: index + 1 }] : []; + }); +}; + +export const toolOutputItems = (content: string, details?: unknown): AgentOutputItem[] => { + const outputs = envelopeOutputs(details); + if (outputs.length) return outputs; + try { return envelopeOutputs(JSON.parse(content)); } + catch { return []; } +}; + +export const formatToolResult = (content: string): string => { + try { return JSON.stringify(JSON.parse(content), null, 2); } + catch { return content; } +}; diff --git a/chat2db-community-client/src/blocks/AI/agentQuestions.test.ts b/chat2db-community-client/src/blocks/AI/agentQuestions.test.ts new file mode 100644 index 0000000000..fd96e7a029 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentQuestions.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import type { AgentEvent } from '../../service/agent'; +import { updateAgentQuestions } from './agentQuestions'; + +const event = (type: AgentEvent['type'], payload: AgentEvent['payload'], runId = 'run', sessionId = 'session'): AgentEvent => ({ + id: `${type}-${runId}`, sessionId, runId, type, sequence: 1, occurredAt: '', payload, +}); +const requested = event('QUESTION_REQUESTED', { questionId: 'q', question: 'Choose', options: [ + { id: 'a', label: 'Same label', description: 'First scope' }, { id: 'b', label: 'Same label', description: 'Second scope' }, +] }); +let items = updateAgentQuestions([], [requested, requested]); +assert.equal(items.length, 1); +assert.equal(items[0].status, 'pending'); +assert.deepEqual(updateAgentQuestions(items, [event('QUESTION_ANSWERED', { questionId: 'q', answer: { optionId: 'b' } }, 'different')]), items); +const answered = event('QUESTION_ANSWERED', { questionId: 'q', answer: { questionId: 'q', optionId: 'b', optionLabel: 'Same label', text: 'Detail' } }); +items = updateAgentQuestions(items, [answered, event('RUN_COMPLETED', {})]); +assert.equal(items[0].status, 'answered'); assert.equal(items[0].answer?.optionId, 'b'); +assert.equal(items[0].answer?.text, 'Detail'); +assert.deepEqual(updateAgentQuestions([], [requested, answered, event('RUN_COMPLETED', {})]), items); +const closed = updateAgentQuestions([], [requested, event('RUN_CANCELLED', {})]); +assert.equal(closed[0].status, 'closed'); +assert.equal(updateAgentQuestions([], [requested, event('QUESTION_CLOSED', { questionId: 'q' })])[0].status, 'closed'); +assert.equal(updateAgentQuestions([], [requested, event('RUN_COMPLETED', {}, 'other')])[0].status, 'pending'); +const textOnly = updateAgentQuestions([], [requested, event('QUESTION_ANSWERED', { questionId: 'q', answer: { text: 'Custom answer' } })]); +assert.equal(textOnly[0].answer?.optionId, undefined); assert.equal(textOnly[0].answer?.text, 'Custom answer'); +console.log('Agent question event lifecycle tests passed'); diff --git a/chat2db-community-client/src/blocks/AI/agentQuestions.ts b/chat2db-community-client/src/blocks/AI/agentQuestions.ts new file mode 100644 index 0000000000..ba16422278 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/agentQuestions.ts @@ -0,0 +1,50 @@ +import type { AgentEvent } from '@/service/agent'; +import type { QuestionAnswer, QuestionOption, QuestionStatus } from '@/types/question'; +import { isTerminalAgentEvent } from './agentEvents'; + +export interface AgentQuestionItem { + id: string; + sessionId: string; + runId: string; + question: string; + options: QuestionOption[]; + status: QuestionStatus; + answer?: QuestionAnswer; +} + +const isOption = (value: unknown): value is QuestionOption => !!value && typeof value === 'object' + && 'id' in value && typeof value.id === 'string' && 'label' in value && typeof value.label === 'string' + && (!('description' in value) || value.description == null || typeof value.description === 'string'); + +export const updateAgentQuestions = (current: AgentQuestionItem[], events: AgentEvent[]): AgentQuestionItem[] => { + const questions = new Map(current.map((item) => [item.id, item])); + for (const event of events) { + const { questionId, question, options, answer } = event.payload; + if (event.type === 'QUESTION_REQUESTED' && typeof questionId === 'string' && typeof question === 'string' + && event.runId && Array.isArray(options) && options.every(isOption) && !questions.has(questionId)) { + questions.set(questionId, { id: questionId, sessionId: event.sessionId, runId: event.runId, + question, options, status: 'pending' }); + } + const item = typeof questionId === 'string' ? questions.get(questionId) : undefined; + if (item && item.sessionId === event.sessionId && item.runId === event.runId) { + if (event.type === 'QUESTION_ANSWERED' && answer && typeof answer === 'object') { + const value = answer as Record; + questions.set(item.id, { ...item, status: 'answered', answer: { + questionId: item.id, + optionId: typeof value.optionId === 'string' ? value.optionId : undefined, + optionLabel: typeof value.optionLabel === 'string' ? value.optionLabel : undefined, + text: typeof value.text === 'string' ? value.text : undefined, + } }); + } + if (event.type === 'QUESTION_CLOSED' && item.status === 'pending') questions.set(item.id, { ...item, status: 'closed' }); + } + if (isTerminalAgentEvent(event)) { + for (const [id, pending] of questions) { + if (pending.sessionId === event.sessionId && pending.runId === event.runId && pending.status === 'pending') { + questions.set(id, { ...pending, status: 'closed' }); + } + } + } + } + return [...questions.values()]; +}; diff --git a/chat2db-community-client/src/blocks/AI/chatCommands.ts b/chat2db-community-client/src/blocks/AI/chatCommands.ts new file mode 100644 index 0000000000..d6e3238486 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/chatCommands.ts @@ -0,0 +1,11 @@ +export const CHAT_COMMANDS = ['new', 'model', 'tools', 'copy', 'export', 'help'] as const; +export type ChatCommand = typeof CHAT_COMMANDS[number]; +export type ConversationCommand = Extract; + +export const parseChatCommand = (input: string): ChatCommand | undefined => { + const name = input.trim().slice(1); + return input.trim().startsWith('/') ? CHAT_COMMANDS.find((command) => command === name) : undefined; +}; + +export const isUnsupportedChatCommand = (input: string) => + /^\/[a-z][a-z-]*(?:\s|$)/i.test(input.trim()) && !parseChatCommand(input); diff --git a/chat2db-community-client/src/blocks/AI/chatSessionRoute.test.ts b/chat2db-community-client/src/blocks/AI/chatSessionRoute.test.ts new file mode 100644 index 0000000000..90da13bffa --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/chatSessionRoute.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { getChatSessionId, getChatSessionUrl, resolveChatSessionVersion } from './chatSessionRoute'; + +assert.equal(getChatSessionId({ pathname: '/', hash: '#/stream/session-one' }), 'session-one'); +assert.equal(getChatSessionId({ pathname: '/stream/session-two', hash: '' }), 'session-two'); +assert.equal(getChatSessionUrl({ pathname: '/', hash: '#/stream' }, 'session-one'), '#/stream/session-one'); +assert.equal(getChatSessionUrl({ pathname: '/stream', hash: '' }, 'session-two'), '/stream/session-two'); +assert.equal(getChatSessionUrl({ pathname: '/', hash: '#/workspace' }, 'session-one'), null); +assert.equal(getChatSessionUrl({ pathname: '/', hash: '#/stream/session-one' }), '#/stream'); + +async function main() { + const listedV1 = await resolveChatSessionVersion('legacy', [ + { id: 'legacy', title: 'Legacy', sessionVersion: 1 }, + ], async () => { throw new Error('probe should not be needed'); }); + assert.deepEqual(listedV1, { id: 'legacy', title: 'Legacy', sessionVersion: 1 }); + + const listedV2 = await resolveChatSessionVersion('session-two', [ + { id: 'session-two', title: 'Pi', sessionVersion: 2 }, + ], async () => { throw new Error('probe should not be needed'); }); + assert.deepEqual(listedV2, { id: 'session-two', title: 'Pi', sessionVersion: 2 }); + + const probedV2 = await resolveChatSessionVersion('session-two', undefined, + async () => ({ title: 'Recovered Pi' })); + assert.deepEqual(probedV2, { id: 'session-two', title: 'Recovered Pi', sessionVersion: 2 }); + + const fallbackV1 = await resolveChatSessionVersion('legacy', undefined, + async () => { throw new Error('not an Agent session'); }); + assert.deepEqual(fallbackV1, { id: 'legacy', sessionVersion: 1 }); +} + +void main(); diff --git a/chat2db-community-client/src/blocks/AI/chatSessionRoute.ts b/chat2db-community-client/src/blocks/AI/chatSessionRoute.ts new file mode 100644 index 0000000000..27e8e953a3 --- /dev/null +++ b/chat2db-community-client/src/blocks/AI/chatSessionRoute.ts @@ -0,0 +1,41 @@ +type RouteLocation = Pick; + +export interface ChatSessionVersionSummary { + id: string; + title?: string; + sessionVersion: 1 | 2; +} + +const routePath = (location: RouteLocation) => + (location.hash.startsWith('#/') ? location.hash.slice(1) : location.pathname).split('?')[0]; + +export const getChatSessionId = (location: RouteLocation) => { + const match = routePath(location).match(/^\/stream\/([^/]+)$/); + return match ? decodeURIComponent(match[1]) : null; +}; + +export const getChatSessionUrl = (location: RouteLocation, sessionId?: string) => { + if (!/^\/stream(?:\/|$)/.test(routePath(location))) return null; + return `${location.hash.startsWith('#/') ? '#' : ''}/stream${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`; +}; + +/** + * Resolve a session version for a deep link. The list endpoint is normally + * authoritative, but a transient list failure must not make a V2 session + * fall through to the legacy history loader. Probe the V2 endpoint before + * treating an unknown session as V1. + */ +export async function resolveChatSessionVersion( + sessionId: string, + sessions: ChatSessionVersionSummary[] | undefined, + probeV2: () => Promise<{ title?: string }>, +): Promise { + const listed = sessions?.find((session) => session.id === sessionId); + if (listed) return listed; + try { + const session = await probeV2(); + return { id: sessionId, title: session.title, sessionVersion: 2 }; + } catch { + return { id: sessionId, sessionVersion: 1 }; + } +} diff --git a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx index f6dfbbba12..af1ca50a94 100644 --- a/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx +++ b/chat2db-community-client/src/blocks/AI/components/AIAtMetion/index.tsx @@ -1,10 +1,11 @@ import React, { useMemo, useState } from 'react'; -import { SuggestionItem } from './interface'; +import { SuggestionItem, SuggestionSelectionIntent } from './interface'; import { useEvent, useMergedState } from 'rc-util'; import { Cascader, CascaderProps } from 'antd'; import useActive from './useActive'; import { useStyles } from './style'; import { IconfontSvg } from '@chat2db/ui'; +import { BookOpen, Command } from 'lucide-react'; export interface RenderChildrenProps { /** @@ -24,7 +25,7 @@ export interface AIAtMetionProps { open?: boolean; onOpenChange?: (open: boolean) => void; - onSelect?: (item: SuggestionItem) => void; + onSelect?: (item: SuggestionItem, intent: SuggestionSelectionIntent) => void; children?: (props: RenderChildrenProps) => React.ReactElement; /** * list of suggestions @@ -78,11 +79,11 @@ function AIAtMetion(props: AIAtMetionProps) { const itemList = useMemo(() => (typeof items === 'function' ? items(info) : items), [items, info]); // =========================== Cascader =========================== - const onInternalChange = (valuePath: string[]) => { + const onInternalChange = (valuePath: string[], intent: SuggestionSelectionIntent = 'execute') => { const value = valuePath.at(-1); const item = itemList.find((candidate) => candidate.value === value); if (onSelect && item) { - onSelect(item); + onSelect(item, intent); } triggerOpen(false); }; @@ -91,15 +92,20 @@ function AIAtMetion(props: AIAtMetionProps) { const [activePath, onKeyDown] = useActive(itemList, mergedOpen, onInternalChange, onClose); const optionRender: CascaderProps['optionRender'] = (node) => { - return ( -
-
+ const icon = node.kind === 'skill' ?