From a1713a5fb76fcda30b77822fbe4ad1c5302338be Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 16:30:01 +0800 Subject: [PATCH 01/25] fix(conversation): wait for explicit run terminal state --- CHANGELOG.md | 7 ++++ package-lock.json | 4 +- package.json | 2 +- src/__tests__/conversation-client.test.ts | 44 ++++++++++++++++++++- src/__tests__/conversation-protocol.test.ts | 36 +++++++++++++++++ src/__tests__/hosted-conversation.test.ts | 4 +- src/__tests__/run-engine.test.ts | 2 +- src/core/conversation/client.ts | 10 ++--- src/core/conversation/presentation.ts | 39 +++++++++++++++--- 9 files changed, 130 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8520627..a7d0ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.4 - 2026-08-31 + +- Keep a conversation turn active until an explicit run-level terminal status + arrives. Completed user messages, tools, approvals, usage reports, and + provider notifications no longer unlock the composer or trigger duplicate + submissions while the agent is still running. + ## 0.3.3 - 2026-08-28 ### Headless conversation surface diff --git a/package-lock.json b/package-lock.json index f6d88a0..cd749b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "license": "Apache-2.0", "dependencies": { "@ag-ui/client": "0.0.57", diff --git a/package.json b/package.json index 8fbb96a..453202a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/__tests__/conversation-client.test.ts b/src/__tests__/conversation-client.test.ts index a185e6d..21e6731 100644 --- a/src/__tests__/conversation-client.test.ts +++ b/src/__tests__/conversation-client.test.ts @@ -165,7 +165,7 @@ describe('HttpConversationClient', () => { operation: 'completed', lifecycle: 'completed', payloadSchemaRef: 'conversation.item.progress/v1', - payload: {}, + payload: { status: 'completed' }, })), ].join('')); } @@ -206,6 +206,48 @@ describe('HttpConversationClient', () => { expect(result.presentation.terminalStatus).toBe('completed'); }); + it('does not treat a completed progress item as a completed run', async () => { + const calls: string[] = []; + const completedNotification = item('source-notification', '', { + itemId: 'notification-1', + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { message: 'autoApprovalReview' }, + }); + const fetcher = vi.fn(async (url: string) => { + calls.push(url); + if (url.includes('conversation:stream')) { + return stream(frame(1, 'item.completed', completedNotification)); + } + return stream(frame(2, 'run.completed', item('source-terminal', '', { + itemId: 'run-end', + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { status: 'completed' }, + }))); + }); + const client = new HttpConversationClient({ + fetch: fetcher, + maxReconnects: 1, + sleep: async () => {}, + }); + + const result = await client.streamTurn({ + bootstrap: { buildId: 'build-1', surface: SURFACE }, + input: input(), + }); + + expect(calls).toEqual([ + '/api/v1/builds/build-1/conversation:stream', + '/api/v1/runs/run-1/events?after=1', + ]); + expect(result.presentation.terminalStatus).toBe('completed'); + }); + it('fails typed when a stream ends before a canonical item supplies run identity', async () => { const fetcher = vi.fn(async () => stream( 'id: 1\nevent: run.created\ndata: {"runId":"not-authoritative-here"}\n\n', diff --git a/src/__tests__/conversation-protocol.test.ts b/src/__tests__/conversation-protocol.test.ts index fc97817..5ace386 100644 --- a/src/__tests__/conversation-protocol.test.ts +++ b/src/__tests__/conversation-protocol.test.ts @@ -163,6 +163,42 @@ describe('ConversationItem/v1 identity reducer', () => { }); describe('ConversationItem/v1 renderer projection', () => { + it('only derives run terminal state from an explicit run status', () => { + let state = createConversationItemState(); + state = reduceConversationItem(state, decodedItem({ + itemId: 'notification-1', + sourceEventIds: ['notification-event'], + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { message: 'autoApprovalReview' }, + })); + expect(projectConversationItems(state).terminalStatus).toBeUndefined(); + + state = reduceConversationItem(state, decodedItem({ + itemId: 'tool-error', + sourceEventIds: ['tool-error-event'], + kind: 'error', + operation: 'completed', + lifecycle: 'failed', + payloadSchemaRef: 'conversation.item.error/v1', + payload: { error: 'one tool failed' }, + })); + expect(projectConversationItems(state).terminalStatus).toBeUndefined(); + + state = reduceConversationItem(state, decodedItem({ + itemId: 'run-end', + sourceEventIds: ['run-end-event'], + kind: 'progress', + operation: 'completed', + lifecycle: 'completed', + payloadSchemaRef: 'conversation.item.progress/v1', + payload: { status: 'completed' }, + })); + expect(projectConversationItems(state).terminalStatus).toBe('completed'); + }); + it('keeps stream order and enriches a tool call with a separate result item', () => { let state = createConversationItemState(); state = reduceConversationItem(state, decodedItem({ diff --git a/src/__tests__/hosted-conversation.test.ts b/src/__tests__/hosted-conversation.test.ts index d26d9f1..6e4a22b 100644 --- a/src/__tests__/hosted-conversation.test.ts +++ b/src/__tests__/hosted-conversation.test.ts @@ -153,7 +153,7 @@ describe('Hosted UI canonical ConversationItem projection', () => { 'event-9', 'progress', 'conversation.item.progress/v1', - {}, + { status: 'completed' }, { operation: 'completed', lifecycle: 'completed' }, )), ].join('')); @@ -249,7 +249,7 @@ describe('Hosted UI canonical ConversationItem projection', () => { 'terminal-event-readonly', 'progress', 'conversation.item.progress/v1', - {}, + { status: 'completed' }, { operation: 'completed', lifecycle: 'completed' }, )), ].join(''))), diff --git a/src/__tests__/run-engine.test.ts b/src/__tests__/run-engine.test.ts index a4b99a6..d4b96ed 100644 --- a/src/__tests__/run-engine.test.ts +++ b/src/__tests__/run-engine.test.ts @@ -143,7 +143,7 @@ function canonicalResult(): ConversationStreamResult { lifecycle: 'completed', visibility: 'public', payloadSchemaRef: 'conversation.item.progress/v1', - payload: {}, + payload: { status: 'completed' }, nativeRef: {}, }, ]; diff --git a/src/core/conversation/client.ts b/src/core/conversation/client.ts index cc7933f..6004a64 100644 --- a/src/core/conversation/client.ts +++ b/src/core/conversation/client.ts @@ -4,7 +4,10 @@ import { preflightConversationInput, } from './contracts.js'; import { ConversationClientError } from './errors.js'; -import { projectConversationItems } from './presentation.js'; +import { + conversationTerminalStatus, + projectConversationItems, +} from './presentation.js'; import { ConversationItemReducer } from './reducer.js'; import type { ConversationClientOptions, @@ -227,10 +230,7 @@ async function consumeEventStream( } function terminal(result: ConversationStreamResult): boolean { - return result.state.items.some((item) => ( - (item.kind === 'progress' || item.kind === 'error') - && (item.lifecycle === 'completed' || item.lifecycle === 'failed') - )); + return result.state.items.some((item) => conversationTerminalStatus(item) !== undefined); } /** diff --git a/src/core/conversation/presentation.ts b/src/core/conversation/presentation.ts index 5580c73..1d44bcc 100644 --- a/src/core/conversation/presentation.ts +++ b/src/core/conversation/presentation.ts @@ -20,10 +20,39 @@ const SUPPORTED_SCHEMAS: Partial> = { error: 'conversation.item.error/v1', }; -function terminal(item: ConversationItem): boolean { +function itemLifecycleTerminal(item: ConversationItem): boolean { return item.lifecycle === 'completed' || item.lifecycle === 'failed'; } +const FAILED_RUN_STATUSES = new Set([ + 'aborted', + 'canceled', + 'cancelled', + 'failed', + 'incomplete', + 'interrupted', +]); + +/** + * Return a run-level terminal state only for an explicit run status. + * + * Item lifecycle is scoped to one item. A completed user message, tool call, + * approval, usage report, or provider notification must never unlock the + * composer while the containing run is still active. + */ +export function conversationTerminalStatus( + item: ConversationItem, +): 'completed' | 'failed' | undefined { + if ((item.kind !== 'progress' && item.kind !== 'error') + || !itemLifecycleTerminal(item)) return undefined; + const status = typeof item.payload.status === 'string' + ? item.payload.status.toLowerCase() + : ''; + if (status === 'completed') return 'completed'; + if (FAILED_RUN_STATUSES.has(status)) return 'failed'; + return undefined; +} + function schemaSupported(item: ConversationItem): boolean { const expected = SUPPORTED_SCHEMAS[item.kind]; return expected === undefined || expected === item.payloadSchemaRef; @@ -93,7 +122,7 @@ function mergeTimelineItem( previous: ConversationItem, incoming: ConversationItem, ): ConversationItem { - const preserveTerminal = terminal(previous) && !terminal(incoming); + const preserveTerminal = itemLifecycleTerminal(previous) && !itemLifecycleTerminal(incoming); return { ...previous, ...incoming, @@ -160,7 +189,7 @@ export function projectConversationItems( ]; const failures = supported.filter((item) => item.kind === 'error'); const terminalItem = [...supported].reverse().find((item) => ( - (item.kind === 'progress' || item.kind === 'error') && terminal(item) + conversationTerminalStatus(item) !== undefined )); return { @@ -197,8 +226,6 @@ export function projectConversationItems( })), ], runId: visible.at(-1)?.runId || '', - terminalStatus: terminalItem - ? terminalItem.lifecycle === 'failed' ? 'failed' : 'completed' - : undefined, + terminalStatus: terminalItem ? conversationTerminalStatus(terminalItem) : undefined, }; } From b13906cfabbe48a205efd52cc9d99b0967f0f8f5 Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 16:30:02 +0800 Subject: [PATCH 02/25] chore(release): attest ksadk-web 0.3.4 source --- RELEASE_PROVENANCE.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index bdcdf05..cfbfa80 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -1,7 +1,7 @@ { "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", - "version": "0.3.3", - "source_commit": "37eb8dd6ae8c44ea4622d39f8c7ae9b13916a793", + "version": "0.3.4", + "source_commit": "a1713a5fb76fcda30b77822fbe4ad1c5302338be", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 79c5ffc66609aae39bcdc5c1c59e07a36601b71d Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 16:32:34 +0800 Subject: [PATCH 03/25] test(conversation): emit explicit terminal status --- e2e/fixtures/canonical-conversation-server.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/fixtures/canonical-conversation-server.mjs b/e2e/fixtures/canonical-conversation-server.mjs index c262c48..b434317 100644 --- a/e2e/fixtures/canonical-conversation-server.mjs +++ b/e2e/fixtures/canonical-conversation-server.mjs @@ -359,6 +359,7 @@ async function streamFirstReconnect(response, requestUrl, request) { sourceEventId: 'event-terminal-1', kind: 'progress', schema: 'conversation.item.progress/v1', + payload: { status: 'completed' }, operation: 'completed', lifecycle: 'completed', })); @@ -403,6 +404,7 @@ async function streamSecondTurn(response) { sourceEventId: 'event-terminal-2', kind: 'progress', schema: 'conversation.item.progress/v1', + payload: { status: 'completed' }, operation: 'completed', lifecycle: 'completed', })); From 7f7e185546464367dae0d9ba7baa473276849a9c Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 16:32:34 +0800 Subject: [PATCH 04/25] chore(release): refresh ksadk-web 0.3.4 provenance --- RELEASE_PROVENANCE.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE_PROVENANCE.json b/RELEASE_PROVENANCE.json index cfbfa80..6182cd3 100644 --- a/RELEASE_PROVENANCE.json +++ b/RELEASE_PROVENANCE.json @@ -2,6 +2,6 @@ "schema_version": 1, "package": "@kingsoftcloud/ksadk-web", "version": "0.3.4", - "source_commit": "a1713a5fb76fcda30b77822fbe4ad1c5302338be", + "source_commit": "79c5ffc66609aae39bcdc5c1c59e07a36601b71d", "interaction_contract_digest": "47e1003e03d97abeba232cc3e03a14b9cbcf78b1109870ccd2ce371f073b6211" } From 207fc6468a7ae4a904ba782f28d6c2d80def866c Mon Sep 17 00:00:00 2001 From: xiayu Date: Mon, 31 Aug 2026 17:22:40 +0800 Subject: [PATCH 05/25] feat(web): publish interactive demo and thinking shimmer --- .github/workflows/pages.yml | 4 +- .github/workflows/publish-npm.yml | 2 +- .gitignore | 1 + CHANGELOG.md | 7 + e2e/demo.spec.ts | 22 ++ package.json | 4 +- playwright.demo.config.mjs | 20 ++ scripts/release-preflight.mjs | 1 + src/__tests__/demo-page-contract.test.ts | 37 +++ src/components/chat/ProcessingBlocksView.tsx | 15 +- src/demo/DemoWorkbench.tsx | 247 +++++++++++++++++++ src/index.css | 36 +++ src/main.tsx | 3 +- tests/package-contract.test.mjs | 5 +- 14 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 e2e/demo.spec.ts create mode 100644 playwright.demo.config.mjs create mode 100644 src/__tests__/demo-page-contract.test.ts create mode 100644 src/demo/DemoWorkbench.tsx diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e5765df..f3a9546 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,11 +25,11 @@ jobs: - run: npm ci - run: npm test - - run: npm run build:ksadk + - run: npm run build:demo - uses: actions/upload-pages-artifact@v3 with: - path: dist-ksadk + path: dist-demo deploy: environment: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index d13fe6b..ccd6eeb 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -55,7 +55,7 @@ jobs: - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: - path: dist-ksadk + path: dist-demo deploy-pages: environment: diff --git a/.gitignore b/.gitignore index 38f67ff..6135b48 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ dist-hosted/ dist-ksadk/ +dist-demo/ dist-lib/ coverage/ output/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d0ea9..aa7415a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ arrives. Completed user messages, tools, approvals, usage reports, and provider notifications no longer unlock the composer or trigger duplicate submissions while the agent is still running. +- Make active reasoning visibly animated with a motion-safe text shimmer, + matching WeWork's low-noise thinking treatment while preserving the compact, + collapsible completed state. +- Publish a dedicated interactive GitHub Pages demo that exercises the shared + reasoning, tool, Markdown, and composer components entirely in the browser. + It is explicitly labelled as local sample data instead of attempting to + connect to a nonexistent Agent backend. ## 0.3.3 - 2026-08-28 diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts new file mode 100644 index 0000000..1e28163 --- /dev/null +++ b/e2e/demo.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; + +test('public demo visibly streams reasoning, a tool, and Markdown without a backend', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'KsADK Web 示例 Agent' })).toBeVisible(); + await expect(page.getByText('本地交互演示', { exact: true })).toBeVisible(); + await expect(page.getByText(/不会连接或冒充真实 Agent/)).toBeVisible(); + + const composer = page.getByRole('textbox', { name: '演示消息' }); + const send = page.getByRole('button', { name: '发送演示消息' }); + await composer.fill('验证公开演示'); + await send.click(); + + await expect(page.getByTestId('thinking-indicator')).toHaveText('正在思考'); + await expect(page.getByTestId('thinking-indicator')).toHaveClass(/waiting-thinking-text/); + await expect(page.getByRole('button', { name: '发送演示消息' })).toBeDisabled(); + await expect(page.getByRole('button', { name: /正在运行 demo\.echo_context/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /已完成 demo\.echo_context/ })).toBeVisible(); + await expect(page.getByText('已收到:“验证公开演示”。')).toBeVisible(); + await expect(page.getByText(/组件会消费同一套会话、思考、工具、审批与 A2UI 协议/)).toBeVisible(); +}); diff --git a/package.json b/package.json index 453202a..51448e7 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "dev": "vite", "build": "npm run build:ksadk", "build:hosted": "VITE_BASE_PATH=/chat/ vite build --outDir dist-hosted", + "build:demo": "VITE_DEMO_MODE=1 VITE_BASE_PATH=./ vite build --outDir dist-demo", "build:lib": "vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json", - "build:all": "npm run build:ksadk && npm run build:hosted && npm run build:lib", + "build:all": "npm run build:ksadk && npm run build:hosted && npm run build:demo && npm run build:lib", "prepack": "npm run build:lib && npm run build:ksadk", "lint": "eslint .", "preview": "vite preview", @@ -16,6 +17,7 @@ "test:watch": "vitest src", "test:e2e:interaction": "playwright test --config playwright.interaction.config.mjs", "test:e2e:conversation": "playwright test --config playwright.canonical-conversation.config.mjs", + "test:e2e:demo": "playwright test --config playwright.demo.config.mjs", "test:e2e:agui": "playwright test --config playwright.agui.config.mjs", "test:e2e:reconnect": "playwright test --config playwright.reconnect.config.mjs", "build:ksadk": "VITE_BASE_PATH=./ vite build --outDir dist-ksadk", diff --git a/playwright.demo.config.mjs b/playwright.demo.config.mjs new file mode 100644 index 0000000..b26f91d --- /dev/null +++ b/playwright.demo.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'demo.spec.ts', + workers: 1, + timeout: 20_000, + expect: { timeout: 10_000 }, + use: { + baseURL: 'http://127.0.0.1:4178', + viewport: { width: 1280, height: 900 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: 'VITE_DEMO_MODE=1 npm run dev -- --host 127.0.0.1 --port 4178 --strictPort', + url: 'http://127.0.0.1:4178', + reuseExistingServer: false, + }, +}); diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index a0dbb62..69c6aee 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -15,6 +15,7 @@ export const RELEASE_COMMANDS = Object.freeze([ ['npm', ['run', 'lint']], ['npm', ['run', 'build:all']], ['npm', ['run', 'test:e2e:conversation']], + ['npm', ['run', 'test:e2e:demo']], ]); function run(command, args, options = {}) { diff --git a/src/__tests__/demo-page-contract.test.ts b/src/__tests__/demo-page-contract.test.ts new file mode 100644 index 0000000..2d7a89f --- /dev/null +++ b/src/__tests__/demo-page-contract.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('public demo page contract', () => { + it('builds a dedicated interactive demo instead of bootstrapping a missing Agent', () => { + const main = readFileSync(resolve(repoRoot, 'src/main.tsx'), 'utf8'); + const demo = readFileSync(resolve(repoRoot, 'src/demo/DemoWorkbench.tsx'), 'utf8'); + const pages = readFileSync(resolve(repoRoot, '.github/workflows/pages.yml'), 'utf8'); + + expect(main).toContain("import.meta.env.VITE_DEMO_MODE === '1'"); + expect(demo).toContain('本地交互演示'); + expect(demo).toContain('不会连接或冒充真实 Agent'); + expect(demo).toContain('setMessages'); + expect(demo).toContain('status: \'streaming\''); + expect(pages).toContain('npm run build:demo'); + expect(pages).toContain('path: dist-demo'); + }); + + it('uses the WeWork-style text shimmer while reasoning is streaming', () => { + const source = readFileSync( + resolve(repoRoot, 'src/components/chat/ProcessingBlocksView.tsx'), + 'utf8', + ); + + const styles = readFileSync(resolve(repoRoot, 'src/index.css'), 'utf8'); + expect(source).toContain("generating && 'waiting-thinking-text'"); + expect(source).toContain("data-testid={generating ? 'thinking-indicator'"); + expect(source).toContain("generating ? '正在思考' : '已思考'"); + expect(styles).toContain('@keyframes waiting-thinking-text'); + expect(styles).toContain('animation: waiting-thinking-text 1.6s linear infinite'); + expect(styles).toContain('@media (prefers-reduced-motion: reduce)'); + }); +}); diff --git a/src/components/chat/ProcessingBlocksView.tsx b/src/components/chat/ProcessingBlocksView.tsx index 765811d..124aea2 100644 --- a/src/components/chat/ProcessingBlocksView.tsx +++ b/src/components/chat/ProcessingBlocksView.tsx @@ -80,13 +80,14 @@ function ThinkingRow({ block }: { block: ThinkingBlock }) { onClick={() => setOpen((value) => !value)} className="flex w-full items-center gap-1.5 rounded px-1 py-1 text-left text-[13px] leading-5 text-slate-400 transition-colors hover:bg-slate-100/70 hover:text-slate-500 dark:text-slate-500 dark:hover:bg-slate-800/40 dark:hover:text-slate-400" > - - {generating ? '思考中' : '已思考'} + {!generating && } + + {generating ? '正在思考' : '已思考'} + {!generating && ( <> · diff --git a/src/demo/DemoWorkbench.tsx b/src/demo/DemoWorkbench.tsx new file mode 100644 index 0000000..6a1d90e --- /dev/null +++ b/src/demo/DemoWorkbench.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'; +import { + Bot, + CheckCircle2, + Code2, + LoaderCircle, + Plus, + Send, + Sparkles, + UserRound, +} from 'lucide-react'; + +import { MessageMarkdown } from '../components/MessageMarkdown'; +import { ProcessingBlocksView } from '../components/chat/ProcessingBlocksView'; +import type { Message } from '../components/chat/types'; + +const initialMessages: Message[] = [ + { + id: 'welcome', + role: 'model', + content: '', + timestamp: Date.now(), + blocks: [ + { + id: 'welcome-thinking', + type: 'thinking', + content: '确认这是不依赖后端的公开演示,并准备展示统一事件渲染。', + status: 'done', + }, + { + id: 'welcome-tool', + type: 'tool', + toolName: 'demo.inspect_capabilities', + args: '{"surface":"chat"}', + output: '{"streaming":true,"reasoning":true,"tools":true}', + status: 'completed', + }, + { + id: 'welcome-text', + type: 'text', + content: '这是 **ksadk-web 交互演示**。你可以发送一条消息,观察思考、工具和正文按同一事件时间线逐步出现。', + status: 'done', + }, + ], + }, +]; + +export function DemoWorkbench() { + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(''); + const [running, setRunning] = useState(false); + const timers = useRef([]); + + useEffect(() => () => { + timers.current.forEach((timer) => window.clearTimeout(timer)); + }, []); + + const schedule = (callback: () => void, delay: number) => { + timers.current.push(window.setTimeout(callback, delay)); + }; + + const updateResponse = (responseId: string, update: (message: Message) => Message) => { + setMessages((current) => current.map((message) => ( + message.id === responseId ? update(message) : message + ))); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const text = input.trim(); + if (!text || running) return; + const stamp = Date.now(); + const responseId = `demo-response-${stamp}`; + setInput(''); + setRunning(true); + setMessages((current) => [ + ...current, + { id: `demo-user-${stamp}`, role: 'user', content: text, timestamp: stamp }, + { + id: responseId, + role: 'model', + content: '', + timestamp: stamp + 1, + blocks: [{ + id: `${responseId}-thinking`, + type: 'thinking', + content: '正在理解你的请求', + status: 'streaming', + }], + }, + ]); + + schedule(() => updateResponse(responseId, (message) => ({ + ...message, + blocks: [{ + id: `${responseId}-thinking`, + type: 'thinking', + content: '正在理解你的请求,并选择适合的演示工具。', + status: 'streaming', + }], + })), 900); + schedule(() => updateResponse(responseId, (message) => ({ + ...message, + blocks: [ + { + id: `${responseId}-thinking`, + type: 'thinking', + content: '已确认输入内容,接下来执行一个只读示例工具。', + status: 'done', + }, + { + id: `${responseId}-tool`, + type: 'tool', + toolName: 'demo.echo_context', + args: JSON.stringify({ input: text }, null, 2), + status: 'running', + }, + ], + tools: { + 'demo.echo_context': { + name: 'demo.echo_context', + args: JSON.stringify({ input: text }, null, 2), + status: 'running', + }, + }, + })), 4000); + schedule(() => updateResponse(responseId, (message) => ({ + ...message, + blocks: [ + ...(message.blocks ?? []).map((block) => ( + block.type === 'tool' ? { ...block, output: '{"ok":true}', status: 'completed' as const } : block + )), + { + id: `${responseId}-text`, + type: 'text', + content: `已收到:“${text}”。\n\n这个页面展示的是本地可交互事件流;接入真实 Agent 时,组件会消费同一套会话、思考、工具、审批与 A2UI 协议。`, + status: 'done', + }, + ], + tools: { + 'demo.echo_context': { + name: 'demo.echo_context', + args: JSON.stringify({ input: text }, null, 2), + output: '{"ok":true}', + status: 'completed', + }, + }, + })), 6500); + schedule(() => setRunning(false), 6700); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }; + + return ( +
+ + +
+
+
+ +
+

KsADK Web 示例 Agent

+

思考 · 工具 · Markdown · 会话状态

+
+
+ 本地交互演示 +
+ +
+
+
+
无需后端即可体验渲染
+

这是公开站点的示例数据,不会连接或冒充真实 Agent。生产接入请使用下方源码与文档。

+
+ + {messages.map((message, index) => message.role === 'user' ? ( +
+
+
+ +
+
+ ) : ( +
+
示例 Agent
+ +
+ ))} +
+
+ +
+
+