feat(core): add stream object api (#12841)

Close [AI-193](https://linear.app/affine-design/issue/AI-193)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for streaming structured AI chat responses as objects,
enabling richer and more interactive chat experiences.
- Chat messages now include a new field displaying structured stream
objects, such as reasoning steps, text deltas, tool calls, and tool
results.
- GraphQL APIs and queries updated to expose these structured streaming
objects in chat histories.
  - Introduced a new streaming chat endpoint for object-based responses.

- **Bug Fixes**
- Improved error handling for streaming responses to ensure more robust
and informative error reporting.

- **Refactor**
- Centralized and streamlined session preparation and streaming logic
for AI chat providers.
  - Unified streaming setup across multiple AI model providers.

- **Tests**
- Extended test coverage for streaming object responses to ensure
reliability and correctness.

- **Documentation**
- Updated type definitions and schemas to reflect new streaming object
capabilities in both backend and frontend code.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
Wu Yue
2025-06-19 09:13:18 +08:00
committed by GitHub
parent ce951ec316
commit 6169cdab3a
24 changed files with 722 additions and 149 deletions
@@ -14,7 +14,7 @@ import {
createExaCrawlTool,
createExaSearchTool,
} from '../tools';
import { PromptMessage } from './types';
import { PromptMessage, StreamObject } from './types';
type ChatMessage = CoreUserMessage | CoreAssistantMessage;
@@ -387,6 +387,22 @@ export interface CustomAITools extends ToolSet {
type ChunkType = TextStreamPart<CustomAITools>['type'];
export function parseUnknownError(error: unknown) {
if (typeof error === 'string') {
throw new Error(error);
} else if (error instanceof Error) {
throw error;
} else if (
typeof error === 'object' &&
error !== null &&
'message' in error
) {
throw new Error(String(error.message));
} else {
throw new Error(JSON.stringify(error));
}
}
export class TextStreamParser {
private readonly CALLOUT_PREFIX = '\n[!]\n';
@@ -446,8 +462,8 @@ export class TextStreamParser {
break;
}
case 'error': {
const error = chunk.error as { type: string; message: string };
throw new Error(error.message);
parseUnknownError(chunk.error);
break;
}
}
this.lastType = chunk.type;
@@ -490,3 +506,54 @@ export class TextStreamParser {
return links;
}
}
export class StreamObjectParser {
public parse(chunk: TextStreamPart<CustomAITools>) {
switch (chunk.type) {
case 'reasoning':
case 'text-delta':
case 'tool-call':
case 'tool-result': {
return chunk;
}
case 'error': {
parseUnknownError(chunk.error);
return null;
}
default: {
return null;
}
}
}
public mergeTextDelta(chunks: StreamObject[]): StreamObject[] {
return chunks.reduce((acc, curr) => {
const prev = acc.at(-1);
switch (curr.type) {
case 'reasoning':
case 'text-delta': {
if (prev && prev.type === curr.type) {
prev.textDelta += curr.textDelta;
} else {
acc.push(curr);
}
break;
}
default: {
acc.push(curr);
break;
}
}
return acc;
}, [] as StreamObject[]);
}
public mergeContent(chunks: StreamObject[]): string {
return chunks.reduce((acc, curr) => {
if (curr.type === 'text-delta') {
acc += curr.textDelta;
}
return acc;
}, '');
}
}