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
148 lines
4.8 KiB
TypeScript
148 lines
4.8 KiB
TypeScript
// @vitest-environment node
|
|
import { MockLanguageModelV3 } from "ai/test"
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
import { POST as validateDiagram } from "@/app/api/validate-diagram/route"
|
|
|
|
const RESULT = {
|
|
valid: false,
|
|
issues: [
|
|
{
|
|
type: "overlap",
|
|
severity: "critical",
|
|
description: "Box A covers box B",
|
|
},
|
|
],
|
|
suggestions: ["Move box B to the right"],
|
|
}
|
|
|
|
// A vision model that answers with the result as JSON text; told to wait,
|
|
// it answers only once its call is aborted
|
|
const model = vi.hoisted(() => ({ waitForAbort: false }))
|
|
vi.mock("@/lib/ai-providers", () => ({
|
|
getValidationModel: () =>
|
|
new MockLanguageModelV3({
|
|
doGenerate: (async (options: { abortSignal?: AbortSignal }) => {
|
|
if (model.waitForAbort) {
|
|
const signal = options.abortSignal
|
|
await new Promise((_, reject) => {
|
|
if (signal?.aborted) reject(signal.reason)
|
|
signal?.addEventListener("abort", () =>
|
|
reject(signal.reason),
|
|
)
|
|
})
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(RESULT) }],
|
|
finishReason: { unified: "stop", raw: "stop" },
|
|
usage: {
|
|
inputTokens: { total: 1 },
|
|
outputTokens: { total: 1 },
|
|
},
|
|
warnings: [],
|
|
}
|
|
}) as any,
|
|
}),
|
|
}))
|
|
|
|
// The quota, off unless a test turns it on
|
|
const quota = vi.hoisted(() => ({
|
|
enabled: false,
|
|
allowed: true,
|
|
checks: [] as Array<{ limits: any; increment?: number }>,
|
|
recorded: [] as number[],
|
|
}))
|
|
vi.mock("@/lib/dynamo-quota-manager", () => ({
|
|
isQuotaEnabled: () => quota.enabled,
|
|
checkAndIncrementRequest: async (
|
|
_ip: string,
|
|
limits: unknown,
|
|
increment?: number,
|
|
) => {
|
|
quota.checks.push({ limits, increment })
|
|
return quota.allowed
|
|
? { allowed: true }
|
|
: {
|
|
allowed: false,
|
|
type: "token",
|
|
error: "Daily token limit exceeded",
|
|
used: 10,
|
|
limit: 10,
|
|
}
|
|
},
|
|
recordTokenUsage: async (_ip: string, tokens: number) => {
|
|
quota.recorded.push(tokens)
|
|
},
|
|
}))
|
|
|
|
const post = (signal?: AbortSignal) =>
|
|
new Request("http://localhost/api/validate-diagram", {
|
|
method: "POST",
|
|
signal,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-forwarded-for": "203.0.113.7",
|
|
},
|
|
body: JSON.stringify({ imageData: "data:image/png;base64,AAAA" }),
|
|
})
|
|
|
|
afterEach(() => {
|
|
model.waitForAbort = false
|
|
quota.enabled = false
|
|
quota.allowed = true
|
|
quota.checks = []
|
|
quota.recorded = []
|
|
})
|
|
|
|
describe("the quota", () => {
|
|
it("refuses a check once the daily tokens are used up", async () => {
|
|
quota.enabled = true
|
|
quota.allowed = false
|
|
const res = await validateDiagram(post())
|
|
expect(res.status).toBe(429)
|
|
})
|
|
|
|
it("applies the token limits only, and records the tokens", async () => {
|
|
// The request limit is for chats: the day's last chat must still
|
|
// get its check, and the check does not count as a chat
|
|
quota.enabled = true
|
|
const res = await validateDiagram(post())
|
|
expect(await res.json()).toEqual(RESULT)
|
|
expect(quota.checks).toHaveLength(1)
|
|
expect(quota.checks[0].increment).toBe(0)
|
|
expect(quota.checks[0].limits.requests).toBe(0)
|
|
expect(quota.recorded).toEqual([2])
|
|
})
|
|
})
|
|
|
|
describe("POST /api/validate-diagram", () => {
|
|
it("answers the model's result as JSON", async () => {
|
|
const res = await validateDiagram(post())
|
|
expect(res.headers.get("content-type")).toContain("application/json")
|
|
expect(await res.json()).toEqual(RESULT)
|
|
})
|
|
|
|
it("ends its model call when the request is aborted", async () => {
|
|
// The user pressed Stop: the browser aborts the request, and the
|
|
// model call must not run on (and be paid for)
|
|
model.waitForAbort = true
|
|
const stop = new AbortController()
|
|
const pending = validateDiagram(post(stop.signal))
|
|
stop.abort()
|
|
expect(await (await pending).json()).toEqual({
|
|
valid: true,
|
|
issues: [],
|
|
suggestions: [],
|
|
})
|
|
})
|
|
|
|
it("answers valid when the check is turned off", async () => {
|
|
vi.stubEnv("ENABLE_VLM_VALIDATION", "false")
|
|
const res = await validateDiagram(post())
|
|
expect(await res.json()).toEqual({
|
|
valid: true,
|
|
issues: [],
|
|
suggestions: [],
|
|
})
|
|
})
|
|
})
|