refactor: simpler streaming preview and small AI SDK cleanups

- useChat throttles streamed message updates (experimental_throttle,
  150 ms), replacing the two hand-written 150 ms timers of the
  display_diagram and edit_diagram previews (94 lines less). The preview
  now only runs while the input streams; once it is complete the tool
  handler takes over, so a queued preview can no longer redraw an edit
  the handler rejected and rolled back. Measured on a streamed 60-cell
  diagram: 41 redraws at least 97 ms apart, before 37 with gaps down to
  48 ms
- The diagram check endpoint uses streamText with Output.object instead
  of the deprecated streamObject, and returns its fixed result as a plain
  text response; new route test
- Import createGateway/gateway from ai and drop the direct
  @ai-sdk/gateway dependency
- The per-request message structure logs only print with
  DEBUG_LLM_PAYLOAD=true
- Remove an empty onFinish callback
This commit is contained in:
dayuan.jiang
2026-10-04 13:16:38 +09:00
parent ac62a58c9f
commit 22a1d3f03b
10 changed files with 206 additions and 222 deletions
+66 -57
View File
@@ -95,6 +95,8 @@ function createCachedStreamResponse(xml: string): Response {
const modelStreamResponses = new WeakSet<Response>()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code
const accessDenied = checkAccessCode(req)
@@ -335,35 +337,37 @@ ${userInputText}
// Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
}
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
@@ -385,34 +389,39 @@ ${userInputText}
// JSON object, and every provider rejects a tool result whose call is gone.
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-call" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
}
// Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) {
+8 -19
View File
@@ -3,7 +3,7 @@
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { Output, streamText } from "ai"
import { checkAccessCode } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
@@ -29,20 +29,9 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
/** A fixed result in the text format useObject reads */
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
@@ -108,9 +97,9 @@ export async function POST(req: Request): Promise<Response> {
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
const result = streamText({
model,
schema: ValidationResultSchema,
output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
@@ -129,10 +118,10 @@ export async function POST(req: Request): Promise<Response> {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ object }) => {
if (sessionId && object) {
onFinish: ({ output }) => {
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
},
+1 -2
View File
@@ -1,13 +1,12 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { createGateway, generateText } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import { checkAccessCode } from "@/lib/access-code"