From 4c4a29f9cfac2bebaa0567f1991f7589bc4cf29f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 29 Mar 2026 12:24:13 +0300 Subject: [PATCH] chore(llm): fix type issues --- apps/client/src/services/in_app_help.ts | 3 +- apps/client/src/services/llm_chat.ts | 7 ++- .../type_widgets/llm_chat/ChatMessage.tsx | 45 +++++++++++---- .../src/services/llm/providers/anthropic.ts | 55 ++++++++++++------- apps/server/src/services/llm/types.ts | 15 ++++- 5 files changed, 89 insertions(+), 36 deletions(-) diff --git a/apps/client/src/services/in_app_help.ts b/apps/client/src/services/in_app_help.ts index ce4c0cdd15..4db04f3c4b 100644 --- a/apps/client/src/services/in_app_help.ts +++ b/apps/client/src/services/in_app_help.ts @@ -19,7 +19,8 @@ export const byNoteType: Record, string | null> = { search: null, text: null, webView: null, - spreadsheet: null + spreadsheet: null, + llmChat: null }; export const byBookType: Record = { diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index 254ff069e1..20f0ea1283 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -16,8 +16,9 @@ export interface ChatConfig { } export interface Citation { - url: string; + url?: string; title?: string; + citedText?: string; } export interface StreamCallbacks { @@ -91,7 +92,9 @@ export async function streamChatCompletion( callbacks.onToolResult?.(data.toolName, data.result); break; case "citation": - callbacks.onCitation?.({ url: data.url, title: data.title }); + if (data.citation) { + callbacks.onCitation?.(data.citation); + } break; case "error": callbacks.onError(data.error); diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index ea986532bb..e5e33757a0 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -88,18 +88,39 @@ export default function ChatMessage({ message, isStreaming }: Props) { {t("llm_chat.sources")}
    - {message.citations.map((citation, idx) => ( -
  • - - {citation.title || new URL(citation.url).hostname} - -
  • - ))} + {message.citations.map((citation, idx) => { + // Determine display text: title, URL hostname, or cited text + let displayText = citation.title; + if (!displayText && citation.url) { + try { + displayText = new URL(citation.url).hostname; + } catch { + displayText = citation.url; + } + } + if (!displayText) { + displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`; + } + + return ( +
  • + {citation.url ? ( + + {displayText} + + ) : ( + + {displayText} + + )} +
  • + ); + })}
)} diff --git a/apps/server/src/services/llm/providers/anthropic.ts b/apps/server/src/services/llm/providers/anthropic.ts index 50973e37b6..d76f5edd6f 100644 --- a/apps/server/src/services/llm/providers/anthropic.ts +++ b/apps/server/src/services/llm/providers/anthropic.ts @@ -1,9 +1,20 @@ import Anthropic from "@anthropic-ai/sdk"; -import type { LlmProvider, LlmMessage, LlmStreamChunk, LlmProviderConfig } from "../types.js"; + +import type { LlmMessage, LlmProvider, LlmProviderConfig,LlmStreamChunk } from "../types.js"; const DEFAULT_MODEL = "claude-sonnet-4-20250514"; const DEFAULT_MAX_TOKENS = 8096; +/** + * Server-side web search tool type. + * Not yet in SDK types as of @anthropic-ai/sdk. + */ +interface WebSearchTool { + type: "web_search_20250305"; + name: "web_search"; + max_uses?: number; +} + export class AnthropicProvider implements LlmProvider { name = "anthropic"; private client: Anthropic; @@ -23,19 +34,18 @@ export class AnthropicProvider implements LlmProvider { const systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content; const chatMessages = messages.filter(m => m.role !== "system"); - // Build tools array - using 'unknown' assertion for server-side tools - // that may not be in the SDK types yet - const tools: unknown[] = []; + // Build tools array + // Using union with WebSearchTool since it's not in SDK types yet + const tools: (Anthropic.ToolUnion | WebSearchTool)[] = []; if (config.enableWebSearch) { tools.push({ type: "web_search_20250305", name: "web_search", max_uses: 5 // Limit searches per request - }); + } satisfies WebSearchTool); } try { - // Cast tools to any since server-side tools may not be in SDK types yet const streamParams: Anthropic.Messages.MessageStreamParams = { model: config.model || DEFAULT_MODEL, max_tokens: config.maxTokens || DEFAULT_MAX_TOKENS, @@ -47,7 +57,8 @@ export class AnthropicProvider implements LlmProvider { }; if (tools.length > 0) { - (streamParams as any).tools = tools; + // Cast needed until SDK adds WebSearchTool type + streamParams.tools = tools as Anthropic.ToolUnion[]; } // Enable extended thinking for deeper reasoning @@ -55,7 +66,7 @@ export class AnthropicProvider implements LlmProvider { const thinkingBudget = config.thinkingBudget || 10000; // max_tokens must be greater than thinking budget streamParams.max_tokens = Math.max(streamParams.max_tokens, thinkingBudget + 4000); - (streamParams as any).thinking = { + streamParams.thinking = { type: "enabled", budget_tokens: thinkingBudget }; @@ -82,7 +93,7 @@ export class AnthropicProvider implements LlmProvider { if (delta.type === "text_delta") { yield { type: "text", content: delta.text }; } else if (delta.type === "thinking_delta") { - yield { type: "thinking", content: (delta as any).thinking }; + yield { type: "thinking", content: delta.thinking }; } else if (delta.type === "input_json_delta") { // Tool input is being streamed - we could accumulate it // For now, we already emitted tool_use at start @@ -102,17 +113,21 @@ export class AnthropicProvider implements LlmProvider { // Get the final message to extract any citations const finalMessage = await stream.finalMessage(); for (const block of finalMessage.content) { - if (block.type === "text") { - // Check for citations in the text block - // Anthropic returns citations as part of the content - if ("citations" in block && Array.isArray((block as any).citations)) { - for (const citation of (block as any).citations) { - yield { - type: "citation", - url: citation.url || citation.source, - title: citation.title - }; - } + if (block.type === "text" && block.citations) { + for (const citation of block.citations) { + // Extract citation info from SDK types (CitationCharLocation, etc.) + // These have: cited_text, document_index, document_title + // Web search citations may have additional properties at runtime + const citationData = citation as unknown as Record; + yield { + type: "citation", + citation: { + title: citation.document_title ?? undefined, + citedText: citation.cited_text, + // URL may be present for web search results (not in SDK types yet) + url: typeof citationData.url === "string" ? citationData.url : undefined + } + }; } } } diff --git a/apps/server/src/services/llm/types.ts b/apps/server/src/services/llm/types.ts index fdcfa91a83..f6bef71b77 100644 --- a/apps/server/src/services/llm/types.ts +++ b/apps/server/src/services/llm/types.ts @@ -8,6 +8,19 @@ export interface LlmMessage { content: string; } +/** + * Citation information extracted from LLM responses. + * May include URL (for web search) or document metadata (for document citations). + */ +export interface LlmCitation { + /** Source URL (typically from web search) */ + url?: string; + /** Document or page title */ + title?: string; + /** The text that was cited */ + citedText?: string; +} + /** * Stream chunk types for real-time updates. */ @@ -16,7 +29,7 @@ export type LlmStreamChunk = | { type: "thinking"; content: string } | { type: "tool_use"; toolName: string; toolInput: Record } | { type: "tool_result"; toolName: string; result: string } - | { type: "citation"; url: string; title?: string } + | { type: "citation"; citation: LlmCitation } | { type: "error"; error: string } | { type: "done" };