UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

509 lines (469 loc) 15.3 kB
/** * Summarize Activity * * Generates summaries from text input. * This is a self-contained module with implementation, types, and JSDoc. */ import { aiEventClient } from '@tanstack/ai-event-client' import { streamGenerationResult } from '../stream-generation-result.js' import { resolveDebugOption } from '../../logger/resolve' import { applyGenerationResultTransforms, createGenerationContext, runGenerationAbort, runGenerationError, runGenerationFinish, runGenerationStart, runGenerationUsage, } from '../middleware/run' import type { InternalLogger } from '../../logger/internal-logger' import type { DebugOption } from '../../logger/types' import type { GenerationMiddleware } from '../middleware/types' import type { SummarizeAdapter } from './adapter' import type { StreamChunk, SummarizationResult } from '../../types' // =========================== // Activity Kind // =========================== /** The adapter kind this activity handles */ export const kind = 'summarize' as const // =========================== // Type Extraction Helpers // =========================== /** Extract provider options from a SummarizeAdapter via ~types */ export type SummarizeProviderOptions<TAdapter> = TAdapter extends SummarizeAdapter<any, any> ? TAdapter['~types']['providerOptions'] : object // =========================== // Activity Options Type // =========================== /** * Options for the summarize activity. * The model is extracted from the adapter's model property. * * @template TAdapter - The summarize adapter type * @template TStream - Whether to stream the output */ export interface SummarizeActivityOptions< TAdapter extends SummarizeAdapter<string, object>, TStream extends boolean = false, > { /** The summarize adapter to use (must be created with a model) */ adapter: TAdapter & { kind: typeof kind } /** The text to summarize */ text: string /** Maximum length of the summary (in words or characters, provider-dependent) */ maxLength?: number /** Style of summary to generate */ style?: 'bullet-points' | 'paragraph' | 'concise' /** Topics or aspects to focus on in the summary */ focus?: Array<string> /** Provider-specific options */ modelOptions?: SummarizeProviderOptions<TAdapter> /** * Optional run identity. When set on a streaming summarize, it is stamped * onto the emitted `RUN_STARTED` so a delivery-durable route keys the run's * log by the same id the client rejoins with — making a mid-run reload * resumable. Filed under `threadId` when persistence is wired. */ runId?: string /** * Stable conversation/thread id for correlating this run when persisted — the * slot a reloading client hydrates the last summary by. Pass it whenever * persistence is on; `withGenerationPersistence` refuses a run without one. */ threadId?: string /** * Observe-only middleware notified on start, usage, success, and error. Pass * `otelMiddleware()` for OpenTelemetry, `withGenerationPersistence()` to * record the run (summaries are text, so the run record holds the result and * there are no artifacts to store), or implement the `GenerationMiddleware` * contract for a custom backend. * * Streaming and non-streaming behave the same way: one `onStart`, then a * terminal `onFinish` / `onError`, with the result transforms applied to the * `SummarizationResult` in between. A streaming consumer that disconnects * mid-summary fires `onAbort`. */ middleware?: Array<GenerationMiddleware> /** * Whether to stream the summarization result. * When true, returns an AsyncIterable<StreamChunk> for streaming output. * When false or not provided, returns a Promise<SummarizationResult>. * * @default false */ stream?: TStream /** * Enable debug logging. Pass `true` to enable all categories, `false` to * silence everything including errors, or a `DebugConfig` object for granular * control and/or a custom `Logger`. */ debug?: DebugOption } // =========================== // Activity Result Type // =========================== /** * Result type for the summarize activity. * - If stream is true: AsyncIterable<StreamChunk> * - Otherwise: Promise<SummarizationResult> */ export type SummarizeActivityResult<TStream extends boolean> = TStream extends true ? AsyncIterable<StreamChunk> : Promise<SummarizationResult> // =========================== // Helper Functions // =========================== function createId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } // =========================== // Activity Implementation // =========================== /** * Summarize activity - generates summaries from text. * * Supports both streaming and non-streaming modes. * * @example Basic summarization * ```ts * import { summarize } from '@tanstack/ai' * import { openaiSummarize } from '@tanstack/ai-openai' * * const result = await summarize({ * adapter: openaiSummarize('gpt-4o-mini'), * text: 'Long article text here...' * }) * * console.log(result.summary) * ``` * * @example Summarization with style * ```ts * const result = await summarize({ * adapter: openaiSummarize('gpt-4o-mini'), * text: 'Long article text here...', * style: 'bullet-points', * maxLength: 100 * }) * ``` * * @example Focused summarization * ```ts * const result = await summarize({ * adapter: openaiSummarize('gpt-4o-mini'), * text: 'Long technical document...', * focus: ['key findings', 'methodology'] * }) * ``` * * @example Streaming summarization * ```ts * for await (const chunk of summarize({ * adapter: openaiSummarize('gpt-4o-mini'), * text: 'Long article text here...', * stream: true * })) { * if (chunk.type === 'content') { * process.stdout.write(chunk.delta) * } * } * ``` */ export function summarize< TAdapter extends SummarizeAdapter<string, object>, TStream extends boolean = false, >( options: SummarizeActivityOptions<TAdapter, TStream>, ): SummarizeActivityResult<TStream> { const { stream } = options if (stream) { return runStreamingSummarize( options as SummarizeActivityOptions< SummarizeAdapter<string, object>, true >, ) as SummarizeActivityResult<TStream> } return runSummarize( options as SummarizeActivityOptions< SummarizeAdapter<string, object>, false >, ) as SummarizeActivityResult<TStream> } /** * Run non-streaming summarization */ async function runSummarize( options: SummarizeActivityOptions<SummarizeAdapter<string, object>, false>, ): Promise<SummarizationResult> { const { adapter, text, maxLength, style, focus, modelOptions, middleware } = options const model = adapter.model const requestId = createId('summarize') const inputLength = text.length const startTime = Date.now() const logger: InternalLogger = resolveDebugOption(options.debug) const mwCtx = createGenerationContext({ requestId, activity: 'summarize', provider: adapter.name, model, modelOptions, threadId: options.threadId, runId: options.runId, createId, }) await runGenerationStart(middleware, mwCtx) aiEventClient.emit('summarize:request:started', { requestId, provider: adapter.name, model, inputLength, timestamp: startTime, }) logger.request(`activity=summarize provider=${adapter.name}`, { provider: adapter.name, model, inputLength, }) const summarizeOptions = { model, text, maxLength, style, focus, modelOptions, logger, } try { const rawResult = await adapter.summarize(summarizeOptions) // Transforms run before anything observes the result — the same order every // media activity uses — so the run record and the returned value are the // same object. const result = await applyGenerationResultTransforms(mwCtx, rawResult) const duration = Date.now() - startTime const outputLength = result.summary.length aiEventClient.emit('summarize:request:completed', { requestId, provider: adapter.name, model, inputLength, outputLength, duration, timestamp: Date.now(), }) logger.output(`activity=summarize length=${outputLength}`, { hasSummary: !!result.summary, outputLength, }) if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage) await runGenerationFinish(middleware, mwCtx, { duration, usage: result.usage, }) return result } catch (error) { await runGenerationError(middleware, mwCtx, { error, duration: Date.now() - startTime, }) logger.errors('summarize activity failed', { error, source: 'summarize', }) throw error } } /** Read a `usage` off a transformed result without asserting its shape. */ function usageOf(result: unknown): SummarizationResult['usage'] | undefined { if (typeof result !== 'object' || result === null) return undefined const usage = (result as { usage?: unknown }).usage return typeof usage === 'object' && usage !== null ? (usage as SummarizationResult['usage']) : undefined } /** * Run streaming summarization * Uses the adapter's native streaming if available, otherwise falls back * to non-streaming and yields the result as a single chunk. */ async function* runStreamingSummarize( options: SummarizeActivityOptions<SummarizeAdapter<string, object>, true>, ): AsyncIterable<StreamChunk> { const { adapter, text, maxLength, style, focus, modelOptions, runId, threadId, } = options const model = adapter.model const logger: InternalLogger = resolveDebugOption(options.debug) logger.request(`activity=summarize provider=${adapter.name}`, { provider: adapter.name, model, stream: true, }) // Thread the caller's run identity through so the emitted `RUN_STARTED` // carries it — keeps a delivery-durable route's log keyed by the id the // client rejoins with (mid-run reload resumability). Conditional spreads keep // the fields off the object entirely under `exactOptionalPropertyTypes`. const summarizeOptions = { model, text, maxLength, style, focus, modelOptions, logger, ...(runId !== undefined ? { runId } : {}), ...(threadId !== undefined ? { threadId } : {}), } // Use real streaming if the adapter supports it if (adapter.summarizeStream) { yield* runNativeSummarizeStream( options, adapter.summarizeStream(summarizeOptions), logger, ) return } try { // Fall back to non-streaming — wrap the result with streamGenerationResult, // forwarding the run identity so its RUN_STARTED matches too. The generation // itself goes through `runSummarize`, so middleware (and its result // transforms) run exactly as they do for a non-streaming call. Only `runId` // is taken from the resolved wire identity — `threadId` stays the CALLER's, // since a minted one would file the run in a slot no client can hydrate. yield* streamGenerationResult( (resolved) => runSummarize({ ...options, stream: false, runId: resolved.runId }), { ...(runId !== undefined ? { runId } : {}), ...(threadId !== undefined ? { threadId } : {}), }, ) } catch (error) { logger.errors('summarize activity failed', { error, source: 'summarize', }) throw error } } /** * Drive an adapter's native `summarizeStream`, wiring the generation middleware * around it. * * The adapter emits a terminal `generation:result` CUSTOM chunk carrying the * assembled {@link SummarizationResult}; that is the one point where a result * exists, so the transforms run there and the REWRITTEN result is what gets * yielded — the client and the persisted run record then hold the same object. * An adapter whose stream never emits one still finishes the run, just with no * result recorded. */ async function* runNativeSummarizeStream( options: SummarizeActivityOptions<SummarizeAdapter<string, object>, true>, stream: AsyncIterable<StreamChunk>, logger: InternalLogger, ): AsyncIterable<StreamChunk> { const { adapter, middleware, modelOptions } = options const mwCtx = createGenerationContext({ requestId: createId('summarize'), activity: 'summarize', provider: adapter.name, model: adapter.model, modelOptions, threadId: options.threadId, runId: options.runId, createId, }) await runGenerationStart(middleware, mwCtx) const startTime = Date.now() // Tracks whether a terminal hook already fired, so the `finally` can report an // abandoned stream without double-firing. Mirrors the streaming video path. let settled = false try { for await (const chunk of stream) { if (chunk.type === 'CUSTOM' && chunk.name === 'generation:result') { const result = await applyGenerationResultTransforms<unknown>( mwCtx, chunk.value, ) const usage = usageOf(result) // Finish before yielding the terminal chunks: a consumer that stops // reading once it has the result must not trip the abandonment path. if (usage) await runGenerationUsage(middleware, mwCtx, usage) await runGenerationFinish(middleware, mwCtx, { duration: Date.now() - startTime, usage, }) settled = true yield { ...chunk, value: result } continue } yield chunk } if (!settled) { await runGenerationFinish(middleware, mwCtx, { duration: Date.now() - startTime, }) settled = true } } catch (error) { settled = true await runGenerationError(middleware, mwCtx, { error, duration: Date.now() - startTime, }) logger.errors('summarize activity failed', { error, source: 'summarize', }) throw error } finally { if (!settled) { // The consumer abandoned the stream mid-summary, so the generator is being // unwound at a `yield`. Report a cancel, not an error, so an observer ends // its span (and persistence marks the run interrupted) instead of leaving // the run open forever. await runGenerationAbort(middleware, mwCtx, { reason: 'Summarize stream abandoned before completion', duration: Date.now() - startTime, }) } } } // =========================== // Options Factory // =========================== /** * Create typed options for the summarize() function without executing. */ export function createSummarizeOptions< TAdapter extends SummarizeAdapter<string, object>, TStream extends boolean = false, >( options: SummarizeActivityOptions<TAdapter, TStream>, ): SummarizeActivityOptions<TAdapter, TStream> { return options } // Re-export adapter types export type { SummarizeAdapter, SummarizeAdapterConfig, AnySummarizeAdapter, } from './adapter' export { BaseSummarizeAdapter } from './adapter' export { ChatStreamSummarizeAdapter, type ChatStreamCapable, type InferTextProviderOptions, } from './chat-stream-summarize'