@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
text/typescript
/**
* 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'