mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
## Problem
A review of how the app uses the Vercel AI SDK (two Claude reviewers and Codex, each finding checked against the SDK's type definitions and source) found five behaviours that are wrong and a set of places where the app re-implements what the SDK already provides:
1. Quota tokens were recorded in `onFinish`, which the SDK does not call when a later step fails; only a user stop was handled (`onAbort`), and the write was not awaited.
2. An automatic retry after a tool error re-sent the request body as it was when the message was sent, so the model was told the "authoritative" canvas XML was the diagram from before the turn, although `display_diagram` commits a drawing before its screenshot check can fail.
3. The screenshot check and the connection test ran their model call on `AbortSignal.timeout` only, so pressing Stop (or closing the dialog) did not stop a paid model call.
4. The client ignored `finishReason`; a reasoning model that spent the whole output budget before calling a tool left the user with no diagram and no message.
5. The connection test counted an `invalid` tool call (a tool that does not exist, unreadable input) as proof of tool support.
## Change
- `onStepFinish` records each step's `usage.totalTokens` and awaits the write; the copies in `onFinish` and `onAbort` are gone.
- `DefaultChatTransport.prepareSendMessagesRequest` sends the current canvas page XML on every request after the first of a turn; the first keeps the exported (or snapshot) XML.
- `validate-diagram` and the connection test pass `req.signal` and use the SDK's `timeout` option; `validate-diagram` reports to Langfuse under `functionId: "validate-diagram"`.
- The connection test only counts a valid tool call.
- The client's `onFinish` shows a notice (with Retry) when `finishReason === "length"` and no tool was called.
- The server validates incoming messages with `safeValidateUIMessages` (400 otherwise), drops client system messages explicitly and prunes empty model messages with `pruneMessages`.
- The chat's tools live in `lib/chat-tools.ts` with an `outputSchema` for the client-side ones; `lib/chat-message.ts` defines `ChatUIMessage` (metadata, a `data-notice` part, `InferUITools`) so `useChat`, `addToolOutput` and the tool handlers are typed.
- Error notices are assistant messages with a `data-notice` part instead of role `system` UI messages.
- Regenerate, edit and retry use `sendMessage({ messageId })`; `isResending` and `flushSync` are gone.
- `isToolUIPart` / `getToolName` replace slicing `"tool-"` off part types; `ToolPartLike` derives from the SDK's part types.
Net: 578 insertions, 439 deletions over 16 files.
## Testing
- New unit tests: a step that fails after a finished step still counts that step's tokens; the screenshot check's model call ends when the request is aborted; an invalid tool call in the connection test gives the warning; malformed messages get a 400. The first and third confirm SDK behaviour the review relied on.
- `tsc`, `biome ci` (0 errors), root `vitest run` 723 passed on the branch rebased onto #986, `next build`: clean
- Playwright, 10 chat-related specs on the dev server: 199 passed, 0 failed, both before and after the rebase onto #986
150 lines
4.4 KiB
TypeScript
150 lines
4.4 KiB
TypeScript
import { LangfuseClient } from "@langfuse/client"
|
|
import { observe, updateActiveTrace } from "@langfuse/tracing"
|
|
import * as api from "@opentelemetry/api"
|
|
import type { TelemetrySettings } from "ai"
|
|
import { randomUUID } from "crypto"
|
|
|
|
// Singleton LangfuseClient instance for direct API calls
|
|
let langfuseClient: LangfuseClient | null = null
|
|
|
|
type IngestionBatch = Parameters<
|
|
LangfuseClient["api"]["ingestion"]["batch"]
|
|
>[0]["batch"]
|
|
|
|
// Attach a score to the session's most recent chat trace. When the session
|
|
// has no trace yet, a fallbackTrace creates a standalone trace to carry the
|
|
// score; without one the score is skipped. Returns whether a score was written.
|
|
export async function scoreLatestTrace(
|
|
langfuse: LangfuseClient,
|
|
opts: {
|
|
sessionId: string
|
|
name: string
|
|
value: number
|
|
comment: string
|
|
fallbackTrace?: {
|
|
name: string
|
|
userId: string
|
|
input: unknown
|
|
metadata: Record<string, unknown>
|
|
}
|
|
},
|
|
): Promise<boolean> {
|
|
const { sessionId, name, value, comment, fallbackTrace } = opts
|
|
const traces = await langfuse.api.trace.list({ sessionId, limit: 1 })
|
|
let traceId = traces.data?.[0]?.id
|
|
const timestamp = new Date().toISOString()
|
|
const batch: IngestionBatch = []
|
|
|
|
if (!traceId) {
|
|
if (!fallbackTrace) return false
|
|
traceId = randomUUID()
|
|
batch.push({
|
|
type: "trace-create",
|
|
id: randomUUID(),
|
|
timestamp,
|
|
body: { id: traceId, sessionId, ...fallbackTrace, timestamp },
|
|
})
|
|
}
|
|
batch.push({
|
|
type: "score-create",
|
|
id: randomUUID(),
|
|
timestamp,
|
|
body: { id: randomUUID(), traceId, name, value, comment },
|
|
})
|
|
await langfuse.api.ingestion.batch({ batch })
|
|
return true
|
|
}
|
|
|
|
export function getLangfuseClient(): LangfuseClient | null {
|
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
|
return null
|
|
}
|
|
|
|
if (!langfuseClient) {
|
|
langfuseClient = new LangfuseClient({
|
|
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
|
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
|
baseUrl: process.env.LANGFUSE_BASEURL,
|
|
})
|
|
}
|
|
|
|
return langfuseClient
|
|
}
|
|
|
|
// Check if Langfuse is configured (both keys required)
|
|
function isLangfuseEnabled(): boolean {
|
|
return !!(
|
|
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
|
)
|
|
}
|
|
|
|
// Update trace with input data at the start of request
|
|
export function setTraceInput(params: {
|
|
input: string
|
|
sessionId?: string
|
|
userId?: string
|
|
}) {
|
|
if (!isLangfuseEnabled()) return
|
|
|
|
updateActiveTrace({
|
|
name: "chat",
|
|
input: params.input,
|
|
sessionId: params.sessionId,
|
|
userId: params.userId,
|
|
})
|
|
}
|
|
|
|
// Update trace with output and end the span
|
|
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
|
|
// so we only need to set the output text and close our wrapper span
|
|
export function setTraceOutput(output: string) {
|
|
if (!isLangfuseEnabled()) return
|
|
|
|
updateActiveTrace({ output })
|
|
endTrace()
|
|
}
|
|
|
|
// End the observe() wrapper span (AI SDK creates its own child spans with usage).
|
|
// It uses endOnExit: false, so every request path has to end it, or the trace
|
|
// is never exported: stream finish, stream error/abort, and early returns.
|
|
export function endTrace() {
|
|
if (!isLangfuseEnabled()) return
|
|
|
|
const activeSpan = api.trace.getActiveSpan()
|
|
if (activeSpan) {
|
|
activeSpan.end()
|
|
}
|
|
}
|
|
|
|
// Telemetry settings for streamText and generateText
|
|
export function getTelemetryConfig(params: {
|
|
sessionId?: string
|
|
userId?: string
|
|
/** Names the trace of a model call outside the chat's observe() span */
|
|
functionId?: string
|
|
}): TelemetrySettings | undefined {
|
|
if (!isLangfuseEnabled()) return undefined
|
|
|
|
return {
|
|
isEnabled: true,
|
|
functionId: params.functionId,
|
|
recordInputs: true,
|
|
recordOutputs: true,
|
|
metadata: {
|
|
...(params.sessionId && { sessionId: params.sessionId }),
|
|
...(params.userId && { userId: params.userId }),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Wrap a handler with Langfuse observe
|
|
export function wrapWithObserve<T>(
|
|
handler: (req: Request) => Promise<T>,
|
|
): (req: Request) => Promise<T> {
|
|
if (!isLangfuseEnabled()) {
|
|
return handler
|
|
}
|
|
|
|
return observe(handler, { name: "chat", endOnExit: false })
|
|
}
|