feat(core): mark reasoning summary as markdown callout (#12176)

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

![截屏2025-05-07 17.40.32.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/e5e7cf4c-0f80-41de-bb22-79a25fdfef48.png)

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

- **New Features**
  - Improved formatting for AI-generated reasoning and web search results, displaying them as callout blocks for enhanced readability.
  - Expanded support for rendering callout blocks in the text renderer.

- **Style**
  - Adjusted layout to ensure callout blocks retain appropriate spacing and appearance.

- **Refactor**
  - Simplified and unified the integration of web search tools for AI providers.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
akumatus
2025-05-08 03:42:21 +00:00
parent 5d2b3e9c4b
commit f8fada0b10
4 changed files with 98 additions and 53 deletions
@@ -11,7 +11,7 @@ import {
metrics, metrics,
UserFriendlyError, UserFriendlyError,
} from '../../../base'; } from '../../../base';
import { createExaTool } from '../tools'; import { createExaSearchTool } from '../tools';
import { CopilotProvider } from './provider'; import { CopilotProvider } from './provider';
import { import {
ChatMessageRole, ChatMessageRole,
@@ -38,7 +38,7 @@ export class AnthropicProvider
private readonly MAX_STEPS = 20; private readonly MAX_STEPS = 20;
private toolResults: string[] = []; private readonly CALLOUT_PREFIX = '\n> [!]\n> ';
#instance!: AnthropicSDKProvider; #instance!: AnthropicSDKProvider;
@@ -134,7 +134,7 @@ export class AnthropicProvider
providerOptions: { providerOptions: {
anthropic: this.getAnthropicOptions(options), anthropic: this.getAnthropicOptions(options),
}, },
tools: this.getTools(options), tools: this.getTools(),
maxSteps: this.MAX_STEPS, maxSteps: this.MAX_STEPS,
experimental_continueSteps: true, experimental_continueSteps: true,
}); });
@@ -166,42 +166,63 @@ export class AnthropicProvider
providerOptions: { providerOptions: {
anthropic: this.getAnthropicOptions(options), anthropic: this.getAnthropicOptions(options),
}, },
tools: this.getTools(options), tools: this.getTools(),
maxSteps: this.MAX_STEPS, maxSteps: this.MAX_STEPS,
experimental_continueSteps: true, experimental_continueSteps: true,
}); });
for await (const message of fullStream) { let lastType;
switch (message.type) { // reasoning, tool-call, tool-result need to mark as callout
let prefix: string | null = this.CALLOUT_PREFIX;
for await (const chunk of fullStream) {
switch (chunk.type) {
case 'text-delta': {
if (!prefix) {
prefix = this.CALLOUT_PREFIX;
}
let result = chunk.textDelta;
if (lastType !== chunk.type) {
result = '\n\n' + result;
}
yield result;
break;
}
case 'reasoning': { case 'reasoning': {
yield message.textDelta; if (prefix) {
yield prefix;
prefix = null;
}
let result = chunk.textDelta;
if (lastType !== chunk.type) {
result = '\n\n' + result;
}
yield this.markAsCallout(result);
break; break;
} }
case 'tool-call': { case 'tool-call': {
if (message.toolName === 'web_search') { if (prefix) {
yield '\n' + `Searching the web "${message.args.query}"` + '\n'; yield prefix;
prefix = null;
}
if (chunk.toolName === 'web_search') {
yield this.markAsCallout(
`\nSearching the web "${chunk.args.query}"\n`
);
} }
break; break;
} }
case 'tool-result': { case 'tool-result': {
if (message.toolName === 'web_search') { if (chunk.toolName === 'web_search') {
this.toolResults.push(this.getWebSearchLinks(message.result)); if (prefix) {
yield prefix;
prefix = null;
}
yield this.markAsCallout(this.getWebSearchLinks(chunk.result));
} }
break; break;
} }
case 'step-finish': {
if (message.finishReason === 'tool-calls') {
yield this.toolResults.join('\n');
this.toolResults = [];
}
break;
}
case 'text-delta': {
yield message.textDelta;
break;
}
case 'error': { case 'error': {
const error = message.error as { type: string; message: string }; const error = chunk.error as { type: string; message: string };
throw new Error(error.message); throw new Error(error.message);
} }
} }
@@ -209,6 +230,7 @@ export class AnthropicProvider
await fullStream.cancel(); await fullStream.cancel();
break; break;
} }
lastType = chunk.type;
} }
} catch (e: any) { } catch (e: any) {
metrics.ai.counter('chat_text_stream_errors').add(1, { model }); metrics.ai.counter('chat_text_stream_errors').add(1, { model });
@@ -216,13 +238,10 @@ export class AnthropicProvider
} }
} }
private getTools(options: CopilotChatOptions) { private getTools() {
if (options?.webSearch) { return {
return { web_search: createExaSearchTool(this.AFFiNEConfig),
web_search: createExaTool(this.AFFiNEConfig), };
};
}
return undefined;
} }
private getAnthropicOptions(options: CopilotChatOptions) { private getAnthropicOptions(options: CopilotChatOptions) {
@@ -243,8 +262,12 @@ export class AnthropicProvider
}[] }[]
): string { ): string {
const links = list.reduce((acc, result) => { const links = list.reduce((acc, result) => {
return acc + `\n[${result.title ?? result.url}](${result.url})\n`; return acc + `\n[${result.title ?? result.url}](${result.url})\n\n`;
}, '\n'); }, '');
return links + '\n'; return links;
}
private markAsCallout(text: string) {
return text.replaceAll('\n', '\n> ');
} }
} }
@@ -19,7 +19,7 @@ import {
metrics, metrics,
UserFriendlyError, UserFriendlyError,
} from '../../../base'; } from '../../../base';
import { createExaTool } from '../tools'; import { createExaSearchTool } from '../tools';
import { CopilotProvider } from './provider'; import { CopilotProvider } from './provider';
import { import {
ChatMessageRole, ChatMessageRole,
@@ -45,7 +45,7 @@ export type OpenAIConfig = {
type OpenAITools = { type OpenAITools = {
web_search_preview: ReturnType<typeof openai.tools.webSearchPreview>; web_search_preview: ReturnType<typeof openai.tools.webSearchPreview>;
web_search: ReturnType<typeof createExaTool>; web_search_exa: ReturnType<typeof createExaSearchTool>;
}; };
export class OpenAIProvider export class OpenAIProvider
@@ -89,6 +89,8 @@ export class OpenAIProvider
private readonly MAX_STEPS = 20; private readonly MAX_STEPS = 20;
private readonly CALLOUT_PREFIX = '\n> [!]\n> ';
#instance!: VercelOpenAIProvider; #instance!: VercelOpenAIProvider;
override configured(): boolean { override configured(): boolean {
@@ -198,7 +200,7 @@ export class OpenAIProvider
case 'webSearch': { case 'webSearch': {
// o series reasoning models // o series reasoning models
if (model.startsWith('o')) { if (model.startsWith('o')) {
tools.web_search = createExaTool(this.AFFiNEConfig); tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
} else { } else {
tools.web_search_preview = openai.tools.webSearchPreview(); tools.web_search_preview = openai.tools.webSearchPreview();
} }
@@ -292,37 +294,52 @@ export class OpenAIProvider
const parser = new CitationParser(); const parser = new CitationParser();
let lastType; let lastType;
// reasoning, tool-call, tool-result need to mark as callout
let prefix: string | null = this.CALLOUT_PREFIX;
for await (const chunk of fullStream) { for await (const chunk of fullStream) {
if (chunk) { if (chunk) {
switch (chunk.type) { switch (chunk.type) {
case 'text-delta': { case 'text-delta': {
let result = parser.parse(chunk.textDelta);
if (lastType !== chunk.type) { if (lastType !== chunk.type) {
yield `\n`; result = '\n\n' + result;
} }
const result = parser.parse(chunk.textDelta);
yield result; yield result;
break; break;
} }
case 'reasoning': { case 'reasoning': {
if (lastType !== chunk.type) { if (prefix) {
yield `\n`; yield prefix;
prefix = null;
} }
yield chunk.textDelta; let result = chunk.textDelta;
if (lastType !== chunk.type) {
result = '\n\n' + result;
}
yield this.markAsCallout(result);
break; break;
} }
case 'tool-call': { case 'tool-call': {
if (chunk.toolName === 'web_search') { if (prefix) {
yield '\n' + `Searching the web "${chunk.args.query}"` + '\n'; yield prefix;
prefix = null;
}
if (chunk.toolName === 'web_search_exa') {
yield this.markAsCallout(
`\nSearching the web "${chunk.args.query}"\n`
);
} }
break; break;
} }
case 'tool-result': { case 'tool-result': {
if (chunk.toolName === 'web_search') { if (chunk.toolName === 'web_search_exa') {
yield '\n' + this.getWebSearchLinks(chunk.result) + '\n'; yield this.markAsCallout(
`\n${this.getWebSearchLinks(chunk.result)}\n`
);
} }
break; break;
} }
case 'step-finish': { case 'finish': {
const result = parser.end(); const result = parser.end();
yield result; yield result;
break; break;
@@ -430,7 +447,7 @@ export class OpenAIProvider
const result: OpenAIResponsesProviderOptions = {}; const result: OpenAIResponsesProviderOptions = {};
if (options?.reasoning) { if (options?.reasoning) {
result.reasoningEffort = 'medium'; result.reasoningEffort = 'medium';
result.reasoningSummary = 'auto'; result.reasoningSummary = 'detailed';
} }
if (options?.user) { if (options?.user) {
result.user = options.user; result.user = options.user;
@@ -445,8 +462,12 @@ export class OpenAIProvider
}[] }[]
): string { ): string {
const links = list.reduce((acc, result) => { const links = list.reduce((acc, result) => {
return acc + `\n[${result.title ?? result.url}](${result.url})\n`; return acc + `\n[${result.title ?? result.url}](${result.url})\n\n`;
}, '\n'); }, '');
return links + '\n'; return links;
}
private markAsCallout(text: string) {
return text.replaceAll('\n', '\n> ');
} }
} }
@@ -4,7 +4,7 @@ import { z } from 'zod';
import { Config } from '../../../base'; import { Config } from '../../../base';
export const createExaTool = (config: Config) => { export const createExaSearchTool = (config: Config) => {
return tool({ return tool({
description: 'Search the web for information', description: 'Search the web for information',
parameters: z.object({ parameters: z.object({
@@ -124,8 +124,8 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
.ai-answer-text-editor { .ai-answer-text-editor {
.affine-note-block-container { .affine-note-block-container {
> .affine-block-children-container { > .affine-block-children-container {
> :first-child, > :first-child:not(affine-callout),
> :first-child * { > :first-child:not(affine-callout) * {
margin-top: 0 !important; margin-top: 0 !important;
} }
> :last-child, > :last-child,
@@ -225,6 +225,7 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
'affine:table', 'affine:table',
'affine:surface', 'affine:surface',
'affine:paragraph', 'affine:paragraph',
'affine:callout',
'affine:code', 'affine:code',
'affine:list', 'affine:list',
'affine:divider', 'affine:divider',