diff --git a/bun.lock b/bun.lock index a453d9713..6fa1307d8 100644 --- a/bun.lock +++ b/bun.lock @@ -235,6 +235,18 @@ "ts-morph": "^27.0.2", }, }, + "packages/thread": { + "name": "@chatjs/thread", + "version": "0.0.0", + "devDependencies": { + "@types/bun": "^1.3.12", + "ai": "^6.0.150", + "typescript": "6.0.2", + }, + "peerDependencies": { + "ai": "^6.0.0", + }, + }, }, "overrides": { "//@better-auth/core": "Pinned because @better-auth/electron and other @better-auth/* packages declare a strict peerDependency on @better-auth/core@1.5.6. Do not remove without auditing all @better-auth/* peer ranges.", @@ -465,6 +477,8 @@ "@chatjs/site": ["@chatjs/site@workspace:apps/site"], + "@chatjs/thread": ["@chatjs/thread@workspace:packages/thread"], + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], @@ -3647,6 +3661,8 @@ "@chatjs/site/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "@chatjs/thread/typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "@chevrotain/cst-dts-gen/@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], "@chevrotain/gast/@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], diff --git a/packages/thread/ARCHITECTURE.md b/packages/thread/ARCHITECTURE.md index b046da2eb..0895af97c 100644 --- a/packages/thread/ARCHITECTURE.md +++ b/packages/thread/ARCHITECTURE.md @@ -80,9 +80,10 @@ await chat.tree.startRun({ follow: false, from: userMessageId }); ``` `from` selects the node that receives the new user message. `follow` controls -whether the cursor moves with the new user and assistant nodes; it defaults to -`true` when the resolved origin equals the active cursor and `false` otherwise. -This includes an explicit `from` whose value equals the current `cursorId`. +whether the cursor selects the new user immediately and its assistant once +streaming begins; it defaults to `true` when the resolved origin equals the +active cursor and `false` otherwise. This includes an explicit `from` whose +value equals the current `cursorId`. Top-level fields always describe the selected path: @@ -168,14 +169,16 @@ role as AI SDK's `Chat.id`, remains stable as messages are added, and is sent to the transport as `chatId` on every request. Within that conversation, each `message.id` identifies one immutable tree node. -An assistant message ID also identifies the run that produces that node. A -branch does not have another stored ID: a message ID identifies its current -head, and following parent links identifies the complete root-to-head path. +Each run has a separate stable ID identifying its request lifecycle. A run is +present while submitted even though no assistant message exists yet, then binds +to the assistant ID produced by AI SDK's stream reducer. A message ID identifies +a branch head, and following parent links identifies the complete root-to-head +path. `cursorId` is the mutable selection of one such head. A followed send attaches -the new user and assistant nodes beneath the selected head and advances the -cursor to those nodes. Starting from an earlier node creates siblings without -changing existing identities or ancestry. +the new user beneath the selected head, then attaches and selects the assistant +when AI SDK first publishes it. Starting from an earlier node creates siblings +without changing existing identities or ancestry. Tree snapshots persist topology and cursor selection, but not `ThreadChat.id`. Callers that restore a conversation associate the snapshot with its stable @@ -206,9 +209,10 @@ type RunRecord = { }; ``` -The assistant message ID is also the run ID. Requests, stream updates, stop -operations, reconnection, and the resulting assistant node therefore share one -stable identity. +Run IDs and message IDs are deliberately separate. A run exists before its +assistant message and keeps the same ID if the server supplies a canonical +message ID. Stop and resume operations address the stable run ID; message-based +helpers resolve the run currently associated with that node. `ThreadChat` creates a `ThreadRunChat` when `sendMessage`, `startRun`, or a reconnection needs an AI SDK request lifecycle. Completed run records are @@ -234,33 +238,23 @@ behavior for: - regeneration and reconnection The internal `ThreadChatState` presents one linear branch path to -`AbstractChat`. It writes accumulated assistant snapshots back to the reserved -assistant node in `ThreadChat`. +`AbstractChat`. It writes accumulated assistant snapshots into `ThreadChat` +when AI SDK publishes the streaming response. The supplied AI SDK `ChatTransport` remains the request and stream boundary. The package does not define another transport protocol or manually parse `UIMessageChunk` values. A delegating transport ensures new and reconnected runs use the latest transport configured on `ThreadChat`. -Each request receives the selected linear path. Optional tree context is added -to `ChatRequestOptions.body`: +Each request receives the selected linear path. `ChatRequestOptions` are passed +to the configured transport unchanged; tree controls never leak into the +application request body. -```ts -{ - assistantMessageId, - tree: { - assistantMessageId, - cursorId, - originCursorId, - parentMessageId, - pathIds, - userMessageId, - }, -} -``` - -The transport can ignore this context when its server only needs linear -messages. +As in AI SDK's `AbstractChat`, a submitted response remains private streaming +state until the first stream write. A server-provided `messageId` in the AI SDK +`start` chunk replaces the provisional ID before publication, so the tree only +ever observes the canonical assistant node. Runs reserve an internal sibling +position so concurrent streams remain ordered by creation rather than arrival. ## Send Lifecycle @@ -269,12 +263,14 @@ A normal `sendMessage` follows this sequence: 1. Resolve the origin from the active cursor or an explicit `tree.from` value. 2. Check concurrency limits and reject without mutating the tree when exceeded. 3. Create or update the user message under that origin. -4. Reserve an assistant message ID and add an assistant shell to the tree. +4. Create a stable run ID and reserve its sibling position without adding a + placeholder message. 5. Create a `RunRecord` and its internal `ThreadRunChat`. 6. Send the selected path through the configured `ChatTransport`. -7. Commit each accumulated assistant snapshot to the reserved tree node. -8. Publish status, error, and finish events for that run. -9. Move the cursor with the run when `follow` is enabled. +7. Let AI SDK apply a server response ID from the start chunk when provided. +8. Insert the assistant node on AI SDK's first write and update it thereafter. +9. Publish status, error, and finish events for that run. +10. Move the cursor with the run when `follow` is enabled. `sendMessage` waits for the request and automatic follow-ups to finish, matching the AI SDK contract. `tree.startRun` returns a handle immediately for callers @@ -376,7 +372,7 @@ useThread({ `maxActiveRuns` limits the complete conversation. `maxActiveRunsPerMessage` limits assistant siblings generated from one user message. A rejected run does -not leave optimistic user or assistant nodes behind. +not leave an extra user message behind. ## Package Boundary @@ -385,7 +381,7 @@ not leave optimistic user or assistant nodes behind. - the `useThread` React contract - tree topology and cursor projection - run creation, registration, and cancellation -- stable assistant/run identity +- stable run identity and server-owned assistant identity - routing tool and approval mutations to their owning runs - adaptation between the tree and AI SDK's linear `AbstractChat` diff --git a/packages/thread/biome.jsonc b/packages/thread/biome.jsonc new file mode 100644 index 000000000..0a1f47739 --- /dev/null +++ b/packages/thread/biome.jsonc @@ -0,0 +1,11 @@ +{ + "formatter": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/packages/thread/package.json b/packages/thread/package.json new file mode 100644 index 000000000..ec64a0b1a --- /dev/null +++ b/packages/thread/package.json @@ -0,0 +1,58 @@ +{ + "name": "@chatjs/thread", + "version": "0.0.0", + "description": "Headless threaded chat engine for AI SDK UI messages", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/franciscomoretti/chat-js.git", + "directory": "packages/thread" + }, + "homepage": "https://github.com/franciscomoretti/chat-js/tree/main/packages/thread", + "bugs": { + "url": "https://github.com/franciscomoretti/chat-js/issues" + }, + "keywords": [ + "chatjs", + "ai", + "ai-sdk", + "thread", + "chat" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "bun": "./src/index.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src", + "README.md", + "ARCHITECTURE.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json && bun build ./src/index.ts --outfile ./dist/index.js --target=browser --format=esm --packages external", + "format": "bunx @biomejs/biome@2.4.10 check --write src *.md package.json tsconfig.json tsconfig.build.json biome.jsonc", + "lint": "bunx @biomejs/biome@2.4.10 check src ARCHITECTURE.md package.json tsconfig.json tsconfig.build.json biome.jsonc", + "prepublishOnly": "bun run build", + "test": "bun test --pass-with-no-tests", + "test:unit": "bun test --pass-with-no-tests", + "test:types": "bun run build" + }, + "peerDependencies": { + "ai": "^6.0.0" + }, + "devDependencies": { + "@types/bun": "^1.3.12", + "ai": "^6.0.150", + "typescript": "6.0.2" + } +} diff --git a/packages/thread/src/index.ts b/packages/thread/src/index.ts new file mode 100644 index 000000000..411a2fa5d --- /dev/null +++ b/packages/thread/src/index.ts @@ -0,0 +1,11 @@ +export type { + MessageTreeNode, + MessageTreeSnapshot, + ThreadChatOptions, + ThreadConcurrency, + ThreadRun, + ThreadRunHandle, + ThreadStartRunOptions, + ThreadStateSnapshot, + TreeSendOptions, +} from "./types.js"; diff --git a/packages/thread/src/types.ts b/packages/thread/src/types.ts new file mode 100644 index 000000000..3b72f6ffc --- /dev/null +++ b/packages/thread/src/types.ts @@ -0,0 +1,75 @@ +import type { + AbstractChat, + ChatInit, + ChatRequestOptions, + ChatStatus, + UIMessage, +} from "ai"; + +export type ThreadRun = { + error: Error | undefined; + id: string; + status: ChatStatus; +}; + +export type ThreadRunHandle = { + readonly finished: Promise; + readonly id: string; + getSnapshot: () => ThreadRun | undefined; + stop: () => Promise; +}; + +export type TreeSendOptions = ChatRequestOptions & { + tree?: { + follow?: boolean; + from?: string | null; + }; +}; + +export type ThreadStartRunOptions = { + follow?: boolean; + from?: string | null; + message?: Parameters["sendMessage"]>[0]; + request?: ChatRequestOptions; +}; + +export type ThreadConcurrency = { + maxActiveRuns?: number; + maxActiveRunsPerMessage?: number; +}; + +export type MessageTreeNode = { + message: TMessage; + parentId: string | null; +}; + +export type MessageTreeSnapshot = { + cursorId: string | null; + nodes: MessageTreeNode[]; + version: 1; +}; + +export type ThreadStateSnapshot = + MessageTreeSnapshot & { + activeRuns: ThreadRun[]; + childrenByParentId: Record; + error: Error | undefined; + messages: TMessage[]; + messagesById: Record; + parentById: Record; + rootIds: string[]; + runs: ThreadRun[]; + status: ChatStatus; + treeStatus: ChatStatus; + }; + +type ThreadInitialState = + | { initialTree: MessageTreeSnapshot; messages?: never } + | { initialTree?: never; messages?: TMessage[] }; + +export type ThreadChatOptions = Omit< + ChatInit, + "messages" +> & { + concurrency?: ThreadConcurrency; +} & ThreadInitialState; diff --git a/packages/thread/tsconfig.build.json b/packages/thread/tsconfig.build.json new file mode 100644 index 000000000..91a6c12e7 --- /dev/null +++ b/packages/thread/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/thread/tsconfig.json b/packages/thread/tsconfig.json new file mode 100644 index 000000000..7bfdd530c --- /dev/null +++ b/packages/thread/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}