mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 01:37:48 +08:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8849c913e3 | ||
|
|
b8b851fc5b | ||
|
|
69c7896002 | ||
|
|
c0fa997186 | ||
|
|
c75f74a5a0 | ||
|
|
4731394f32 | ||
|
|
50c7ad3ec4 | ||
|
|
cfbf17f97e | ||
|
|
49468f9478 | ||
|
|
d5f31cb253 | ||
|
|
080f44716f | ||
|
|
2e84e02666 | ||
|
|
963839c4a9 | ||
|
|
0855b35ff2 | ||
|
|
504d2fa812 | ||
|
|
2bb69b58af | ||
|
|
48128acc65 | ||
|
|
153fa9e5f4 | ||
|
|
2ac4c54eb2 | ||
|
|
65e3dd1dde | ||
|
|
89a712bca4 | ||
|
|
6a99915446 | ||
|
|
6f5f7b668b | ||
|
|
6236124338 | ||
|
|
ed1722ee9d | ||
|
|
ab1a58f999 | ||
|
|
99890e9e37 | ||
|
|
c24aae6de0 | ||
|
|
b7c26916cd | ||
|
|
22a1d3f03b | ||
|
|
ac62a58c9f | ||
|
|
d32eb25523 | ||
|
|
bf4e95dcfb | ||
|
|
e49e9f0c47 | ||
|
|
b7c543ca70 | ||
|
|
289e6bc25f | ||
|
|
899924ba98 | ||
|
|
392c8af84a | ||
|
|
127562b0e5 | ||
|
|
f52f95025f | ||
|
|
667f678eb3 | ||
|
|
81ad317375 | ||
|
|
9de281627e | ||
|
|
6d67a0ec69 |
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Run Biome format
|
||||
# Pin to the version in package.json so CI matches local/pre-commit
|
||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
|
||||
run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
|
||||
@@ -62,6 +62,10 @@ jobs:
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Build and check package contents
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm run build && npm run check-package
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
# Tests run from src/, so check the built npm package separately
|
||||
- name: Build MCP server and check package contents
|
||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -138,6 +138,13 @@ Then ask Claude to create diagrams:
|
||||
|
||||
The diagram appears in your browser in real-time!
|
||||
|
||||
The MCP server includes most of the web app's drawing features:
|
||||
|
||||
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
||||
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
||||
|
||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
@@ -88,6 +89,11 @@ function ProviderDetail({
|
||||
try {
|
||||
const data = await adminFetch("/api/admin/test-model", password, {
|
||||
method: "POST",
|
||||
// EdgeOne's function also checks the access code
|
||||
headers: {
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
|
||||
+10
-3
@@ -107,8 +107,8 @@ export default function Home() {
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile. The draw.io iframe is not remounted when crossing the
|
||||
// breakpoint (only the chat panel is), so its ready state stays as is.
|
||||
// Check mobile. No panel is remounted when crossing the breakpoint, so
|
||||
// the draw.io ready state and the chat's turn stay as they are.
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
@@ -119,6 +119,14 @@ export default function Home() {
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
// Give the chat panel the size of this side of the breakpoint. It is
|
||||
// open on both sides: the mobile panel cannot be collapsed, and one
|
||||
// collapsed on desktop comes back open
|
||||
useEffect(() => {
|
||||
chatPanelRef.current?.resize(isMobile ? 50 : 33)
|
||||
setIsChatVisible(true)
|
||||
}, [isMobile])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
if (panel) {
|
||||
@@ -212,7 +220,6 @@ export default function Home() {
|
||||
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
id="chat-panel"
|
||||
ref={chatPanelRef}
|
||||
defaultSize={isMobile ? 50 : 33}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -47,14 +48,31 @@ export async function POST(req: Request) {
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
const serverUrl = globalBaseUrl(resolved.provider)
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Checked again there, in place of an access code
|
||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||
// The EdgeOne function checks the access code and Pages
|
||||
// cookies, and its URL is built from the page's origin
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
...(req.headers.get("origin") && {
|
||||
origin: req.headers.get("origin") as string,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
// Without a URL of its own, chat sends the entry's key to
|
||||
// the server's <P>_BASE_URL: test that endpoint, not
|
||||
// another one. It is the server's own, which chat uses
|
||||
// without the checks for a URL a user typed.
|
||||
baseUrl: resolved.baseUrl || serverUrl,
|
||||
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
AIHUBMIX_MODELS_ENDPOINT,
|
||||
extractAihubmixModelIds,
|
||||
} from "@/lib/aihubmix-models"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
|
||||
const SUCCESS_CACHE_CONTROL =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
||||
|
||||
function fallbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: SUGGESTED_MODELS.aihubmix || [],
|
||||
source: "fallback",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
||||
)
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const models = extractAihubmixModelIds(payload)
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn("[aihubmix-models] Model list response was empty")
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
models,
|
||||
source: "aihubmix",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
||||
return fallbackResponse()
|
||||
}
|
||||
}
|
||||
+221
-254
@@ -4,29 +4,31 @@ import {
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
InvalidToolInputError,
|
||||
LoadAPIKeyError,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import {
|
||||
CACHE_POINT,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
isMinimalDiagram,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
|
||||
import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
@@ -49,7 +52,14 @@ import {
|
||||
} from "@/lib/server-model-config"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
getShapeLibrary,
|
||||
SHAPE_LIBRARY_LIST,
|
||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
||||
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
@@ -89,7 +99,11 @@ 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> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Check for access code
|
||||
const accessDenied = checkAccessCode(req)
|
||||
if (accessDenied) return accessDenied
|
||||
@@ -125,36 +139,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||
const hasOwnApiKey = !!(
|
||||
req.headers.get("x-ai-provider") &&
|
||||
(req.headers.get("x-ai-api-key") ||
|
||||
req.headers.get("x-aws-access-key-id") ||
|
||||
req.headers.get("x-vertex-api-key"))
|
||||
)
|
||||
|
||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages)
|
||||
if (!fileValidation.valid) {
|
||||
@@ -164,7 +148,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
||||
|
||||
if (isFirstMessage && isEmptyDiagram) {
|
||||
const lastMessage = messages[0]
|
||||
@@ -184,25 +168,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
let baseUrl = req.headers.get("x-ai-base-url")
|
||||
const selectedModelId = req.headers.get("x-selected-model-id")
|
||||
|
||||
// For EdgeOne provider, construct full URL from request origin
|
||||
// because createOpenAI needs absolute URL, not relative path
|
||||
if (provider === "edgeone" && !baseUrl) {
|
||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
||||
baseUrl = `${origin}/api/edgeai`
|
||||
}
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return Response.json(
|
||||
{ error: "Private or internal base URLs are not allowed." },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
// Check if this is a server model with custom env var names
|
||||
let serverModelConfig: {
|
||||
apiKeyEnv?: string | string[]
|
||||
@@ -225,9 +190,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return Response.json(
|
||||
{ error: "Private or internal base URLs are not allowed." },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header
|
||||
provider: serverModelConfig.provider || provider,
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
@@ -243,7 +236,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(provider === "edgeone" && {
|
||||
...(isEdgeOne && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
@@ -262,21 +255,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const {
|
||||
model: baseModel,
|
||||
providerOptions,
|
||||
headers,
|
||||
modelId,
|
||||
provider: resolvedProvider,
|
||||
} = getAIModel(clientOverrides)
|
||||
|
||||
// On the server's own keys, only run models the server offers: a server
|
||||
// model picked by id (its model name is fixed above) or one in AI_MODEL.
|
||||
// With their own key, users can run any model.
|
||||
// model picked by id (its model name is fixed above) or one in AI_MODEL
|
||||
// on AI_PROVIDER. With their own key, users can run any model.
|
||||
const onServerCredentials = usesServerCredentials(
|
||||
resolvedProvider,
|
||||
clientOverrides,
|
||||
)
|
||||
const envModels =
|
||||
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
|
||||
if (onServerCredentials && !serverModel && !envModels.includes(modelId)) {
|
||||
const offeredInEnv =
|
||||
envModels.includes(modelId) && resolvedProvider === getServerProvider()
|
||||
if (onServerCredentials && !serverModel && !offeredInEnv) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
|
||||
@@ -285,14 +279,56 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
// Retry with a smaller budget if the provider rejects the requested one
|
||||
const model = withOutputTokenLimitFallback(baseModel)
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
|
||||
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
|
||||
// Ollama, and anything at a private address (the server's network,
|
||||
// which ignores a dummy key header). Bedrock and EdgeOne never use the
|
||||
// base URL header. In the desktop app every endpoint is the user's.
|
||||
const clientBaseUrl = normalizeBaseUrl(
|
||||
req.headers.get("x-ai-base-url") ?? "",
|
||||
)
|
||||
const onServerEndpoint = await usesServerEndpoint(
|
||||
resolvedProvider,
|
||||
clientBaseUrl,
|
||||
clientOverrides.apiKey,
|
||||
)
|
||||
const countsQuota =
|
||||
isQuotaEnabled() &&
|
||||
(onServerCredentials || onServerEndpoint) &&
|
||||
userId !== "anonymous"
|
||||
if (countsQuota) {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// The user setting can raise the budget only on their own key (desktop users
|
||||
// can still raise it themselves); on the server's keys it can only lower it
|
||||
// Retry once if the provider rejects the requested budget, or (newer
|
||||
// Claude models) the sampling or thinking settings
|
||||
const model = withOutputTokenLimitFallback(
|
||||
withDeprecatedParamsFallback(baseModel),
|
||||
)
|
||||
|
||||
// The user setting can raise the budget only on their own key (in the
|
||||
// desktop app every key is the user's); on the server's keys or own
|
||||
// endpoints it can only lower it
|
||||
const maxOutputTokens = resolveMaxOutputTokens(
|
||||
req.headers.get("x-max-output-tokens"),
|
||||
onServerCredentials,
|
||||
onServerCredentials || onServerEndpoint,
|
||||
)
|
||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||
|
||||
@@ -324,39 +360,46 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
${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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||
// call that never got its result (the user stopped while it ran) is
|
||||
// left out: the SDK would refuse this and every later request of the
|
||||
// chat (MissingToolResultsError)
|
||||
const modelMessages = await convertToModelMessages(messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
})
|
||||
|
||||
// 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
|
||||
const enableHistoryReplace =
|
||||
@@ -377,34 +420,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) {
|
||||
@@ -440,9 +488,7 @@ ${userInputText}
|
||||
if (enhancedMessages[i].role === "assistant") {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
providerOptions: CACHE_POINT,
|
||||
}
|
||||
break // Only cache the last assistant message
|
||||
}
|
||||
@@ -495,28 +541,26 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
{
|
||||
role: "system" as const,
|
||||
content: finalSystemMessage,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: xmlContext,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
]
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
// Set by onAbort, which records the finished steps' tokens itself
|
||||
let stopped = false
|
||||
const result = streamText({
|
||||
model,
|
||||
// The system messages carry cache points, so they go in messages.
|
||||
// A client's own system messages have string content and were
|
||||
// dropped by the empty-content filter above.
|
||||
allowSystemInMessages: true,
|
||||
abortSignal: req.signal,
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
@@ -562,7 +606,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
@@ -577,12 +620,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!hasOwnApiKey &&
|
||||
userId !== "anonymous" &&
|
||||
totalUsage
|
||||
) {
|
||||
if (countsQuota && totalUsage && !stopped) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0)
|
||||
@@ -594,7 +632,23 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
console.error(error) // what AI SDK does without an onError
|
||||
endTrace()
|
||||
},
|
||||
onAbort: () => endTrace(),
|
||||
onAbort: ({ steps }) => {
|
||||
stopped = true
|
||||
endTrace()
|
||||
// Stopped (or disconnected) after some steps finished: their
|
||||
// tokens were used, or stopping every request after a costly
|
||||
// first step would get around the token limits
|
||||
if (countsQuota) {
|
||||
const tokens = steps.reduce(
|
||||
(sum, step) =>
|
||||
sum +
|
||||
(step.usage.inputTokens || 0) +
|
||||
(step.usage.outputTokens || 0),
|
||||
0,
|
||||
)
|
||||
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
@@ -609,21 +663,7 @@ VALIDATION RULES (XML will be rejected if violated):
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
${SWIMLANE_EXAMPLE}
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
@@ -701,14 +741,7 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
${SHAPE_LIBRARY_LIST}
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
@@ -719,45 +752,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Sanitize input - prevent path traversal attacks
|
||||
const sanitizedLibrary = library
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
|
||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||
}
|
||||
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"docs/shape-libraries",
|
||||
// Only known library names reach the file system
|
||||
const result = await getShapeLibrary(
|
||||
library,
|
||||
path.join(process.cwd(), "docs/shape-libraries"),
|
||||
)
|
||||
const filePath = path.join(
|
||||
baseDir,
|
||||
`${sanitizedLibrary}.md`,
|
||||
)
|
||||
|
||||
// Verify path stays within expected directory
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||
return `Invalid library path.`
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
error,
|
||||
)
|
||||
return `Error loading library "${library}". Please try again.`
|
||||
}
|
||||
return result.ok ? result.text : result.error
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -768,6 +768,10 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
// On the server's keys the provider's text can name its account.
|
||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||
// user's own, and EdgeOne's text is our function's explanation.
|
||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
@@ -784,61 +788,24 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper to categorize errors and return appropriate response
|
||||
// Errors before the stream starts, as JSON the chat panel reads
|
||||
function handleError(error: unknown): Response {
|
||||
console.error("Error in chat route:", error)
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
// Check for specific AI SDK error types
|
||||
if (APICallError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: error.message,
|
||||
...(isDev && {
|
||||
details: error.responseBody,
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: error.statusCode || 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Authentication failed. Please check your API key.",
|
||||
...(isDev && {
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for other errors with safety filter
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
const status = (error as any)?.statusCode || (error as any)?.status || 500
|
||||
|
||||
// Prevent leaking API keys, tokens, or other sensitive data
|
||||
const lowerMessage = message.toLowerCase()
|
||||
const safeMessage =
|
||||
lowerMessage.includes("key") ||
|
||||
lowerMessage.includes("token") ||
|
||||
lowerMessage.includes("sig") ||
|
||||
lowerMessage.includes("signature") ||
|
||||
lowerMessage.includes("secret") ||
|
||||
lowerMessage.includes("password") ||
|
||||
lowerMessage.includes("credential")
|
||||
? "Authentication failed. Please check your credentials."
|
||||
: message
|
||||
const classified = classifyLLMError(error)
|
||||
const status =
|
||||
(error as { statusCode?: number })?.statusCode ||
|
||||
(error as { status?: number })?.status ||
|
||||
(classified.code === "invalid_api_key" ? 401 : 500)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: safeMessage,
|
||||
...classified,
|
||||
...(isDev && {
|
||||
details: message,
|
||||
details: APICallError.isInstance(error)
|
||||
? error.responseBody
|
||||
: undefined,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(["drawio", "png", "svg"]),
|
||||
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
@@ -34,33 +35,9 @@ function detectCharset(
|
||||
}
|
||||
}
|
||||
|
||||
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
|
||||
// huge download can't exhaust server memory. Returns null when too large.
|
||||
async function readLimitedBody(
|
||||
response: Response,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
|
||||
return null
|
||||
}
|
||||
if (!response.body) return new ArrayBuffer(0)
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel()
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return new Blob(chunks as BlobPart[]).arrayBuffer()
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
@@ -128,7 +105,7 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await readLimitedBody(response)
|
||||
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
|
||||
if (!buffer) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -154,6 +131,9 @@ export async function POST(req: Request) {
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
// Ends a download left unread (too large, PDF, error status);
|
||||
// a body already read is not affected
|
||||
controller.abort()
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import {
|
||||
canListModels,
|
||||
listProviderModels,
|
||||
ModelListError,
|
||||
} from "@/lib/provider-models"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
RedirectRefusedError,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
// Public lists need no key
|
||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
])
|
||||
|
||||
/**
|
||||
* The models a provider offers, for the "Fetch models" button in model
|
||||
* settings. Answers { models: null } for providers that cannot list them,
|
||||
* so the dialog keeps its suggested models.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Sends requests to a URL the client chose, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
||||
provider: ProviderName
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}
|
||||
if (!canListModels(provider)) {
|
||||
return NextResponse.json({ models: null })
|
||||
}
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||
}
|
||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await listProviderModels(
|
||||
provider,
|
||||
{ apiKey, baseUrl },
|
||||
(baseUrl && redirectGuardedFetch()) || fetch,
|
||||
)
|
||||
return NextResponse.json({ models })
|
||||
} catch (error) {
|
||||
console.warn("[provider-models] Listing failed:", error)
|
||||
// Only our own explanations go back: the URL may be an internal
|
||||
// address, whose answer or host names must not reach the caller.
|
||||
// The Gateway SDK wraps them, keeping ours as the cause.
|
||||
const isOwn = (e: unknown): e is Error =>
|
||||
e instanceof ModelListError || e instanceof RedirectRefusedError
|
||||
const cause = (error as { cause?: unknown })?.cause
|
||||
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
||||
const { code } = classifyLLMError(own ?? error)
|
||||
return NextResponse.json({
|
||||
code,
|
||||
error: own?.message ?? "The model list request failed.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,15 @@
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
*/
|
||||
|
||||
import { streamObject } from "ai"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { Output, streamText } from "ai"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
type ValidationResult,
|
||||
@@ -29,25 +35,16 @@ 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" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Uses the server's model credentials, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
@@ -87,6 +84,35 @@ export async function POST(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
// It runs the server's vision model: with the quota on, the daily
|
||||
// and per-minute token limits apply, and its tokens are counted. Not
|
||||
// the request limit, which is for chats: the day's last chat still
|
||||
// gets its check, and a check does not count as a chat.
|
||||
const userId = getUserIdFromRequest(req)
|
||||
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
|
||||
if (countsQuota) {
|
||||
const quotaCheck = await checkAndIncrementRequest(
|
||||
userId,
|
||||
{
|
||||
requests: 0,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
},
|
||||
0,
|
||||
)
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the validation model
|
||||
let model
|
||||
try {
|
||||
@@ -108,9 +134,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 +155,17 @@ export async function POST(req: Request): Promise<Response> {
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ object }) => {
|
||||
if (sessionId && object) {
|
||||
onFinish: ({ output, totalUsage }) => {
|
||||
if (countsQuota && totalUsage) {
|
||||
recordTokenUsage(
|
||||
userId,
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0),
|
||||
)
|
||||
}
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+152
-410
@@ -1,28 +1,28 @@
|
||||
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 { streamText, tool } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
normalizeMiniMaxBaseURL,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
globalBaseUrl,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: string
|
||||
provider: ProviderName
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
@@ -30,38 +30,42 @@ interface ValidateRequest {
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
awsSessionToken?: string
|
||||
// Vertex AI specific
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||
serverBaseUrl?: boolean
|
||||
}
|
||||
|
||||
// With private URLs blocked, a public baseUrl could still redirect the
|
||||
// request to an internal host, so redirects are refused in that case.
|
||||
function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
if (allowPrivateUrls()) return undefined
|
||||
return async (input, init) => {
|
||||
const response = await fetch(input, { ...init, redirect: "manual" })
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new Error("Redirects are not allowed for custom base URLs")
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
const TEST_TIMEOUT_MS = 15_000
|
||||
|
||||
// Drawing works through tool calls, so the test asks for one
|
||||
const PING_TOOL = tool({
|
||||
description: "Report that the connection works.",
|
||||
inputSchema: z.object({}),
|
||||
})
|
||||
|
||||
const NO_TOOL_CALL_WARNING =
|
||||
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Lets the server send requests to arbitrary URLs, so require the access
|
||||
// code, or the admin password (the admin panel's Test button)
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
if (accessError && checkAdminAuth(req)) return accessError
|
||||
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
awsSessionToken,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
@@ -72,9 +76,26 @@ export async function POST(req: Request) {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// EdgeOne is this site's own function, as in the chat; the admin
|
||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||
const baseUrl =
|
||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||
// The admin panel's Test of an entry without a URL sends the
|
||||
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||
// user chose, so no private-address or redirect rules
|
||||
const serverUrl =
|
||||
body.serverBaseUrl === true &&
|
||||
!!baseUrl &&
|
||||
baseUrl === globalBaseUrl(provider) &&
|
||||
!checkAdminAuth(req)
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
if (
|
||||
baseUrl &&
|
||||
!serverUrl &&
|
||||
!allowPrivateUrls() &&
|
||||
(await isPrivateUrl(baseUrl))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -108,396 +129,117 @@ export async function POST(req: Request) {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const guardedFetch = redirectGuardedFetch()
|
||||
let model: any
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "anthropic": {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = anthropic(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = google(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "vertexai": {
|
||||
const vertex = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = vertex(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "azure": {
|
||||
const azure = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = azure.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "bedrock": {
|
||||
const bedrock = createAmazonBedrock({
|
||||
accessKeyId: awsAccessKeyId,
|
||||
secretAccessKey: awsSecretAccessKey,
|
||||
region: awsRegion,
|
||||
})
|
||||
model = bedrock(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseUrl) ||
|
||||
baseUrl === defaultBaseURL
|
||||
) {
|
||||
const aihubmix = createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
model = aihubmix(modelId)
|
||||
} else {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = ds(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "siliconflow": {
|
||||
const sf = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
||||
// OLLAMA_API_KEY when the URL is also from server config.
|
||||
const ollamaApiKey = baseUrl
|
||||
? apiKey || undefined
|
||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
||||
const ollamaProvider = createOllama({
|
||||
baseURL:
|
||||
baseUrl ||
|
||||
process.env.OLLAMA_BASE_URL ||
|
||||
"https://ollama.com/api",
|
||||
fetch: guardedFetch,
|
||||
...(ollamaApiKey && {
|
||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||
}),
|
||||
})
|
||||
model = ollamaProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "edgeone": {
|
||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
||||
// Need to pass cookies for EdgeOne Pages authentication,
|
||||
// and the access code, which the edge function also checks
|
||||
const cookieHeader = req.headers.get("cookie") || ""
|
||||
const edgeone = createOpenAI({
|
||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
||||
baseURL: baseUrl || "/api/edgeai",
|
||||
fetch: guardedFetch,
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
})
|
||||
model = edgeone.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "sglang": {
|
||||
// SGLang is OpenAI-compatible
|
||||
const sglang = createOpenAI({
|
||||
apiKey: apiKey || "not-needed",
|
||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = sglang.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "doubao": {
|
||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||
const doubaoBaseUrl =
|
||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
if (
|
||||
lowerModelId.includes("deepseek") ||
|
||||
lowerModelId.includes("kimi")
|
||||
) {
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = doubao(modelId)
|
||||
} else {
|
||||
const doubao = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = doubao.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "modelscope": {
|
||||
const baseURL =
|
||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||
const response = await (guardedFetch ?? fetch)(
|
||||
`${baseURL}/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: "user", content: "Say 'OK'" },
|
||||
],
|
||||
max_tokens: 20,
|
||||
stream: true,
|
||||
enable_thinking: false,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
// Log the body but return only the status: the
|
||||
// caller chooses baseUrl, so the body may come from
|
||||
// any host the server can reach
|
||||
console.error(
|
||||
"[validate-model] ModelScope error body:",
|
||||
await response.text(),
|
||||
)
|
||||
throw new Error(
|
||||
`ModelScope API error (${response.status})`,
|
||||
)
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") || ""
|
||||
const isValidStreamingResponse =
|
||||
response.status === 200 &&
|
||||
(contentType.includes("text/event-stream") ||
|
||||
contentType.includes("application/json"))
|
||||
|
||||
if (!isValidStreamingResponse) {
|
||||
throw new Error(
|
||||
`Unexpected response format: ${contentType}`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
if (response.body) {
|
||||
response.body.cancel().catch(() => {
|
||||
/* Ignore cancellation errors */
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
note: "ModelScope model validated (using streaming API)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[validate-model] ModelScope validation failed:",
|
||||
error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
case "minimax": {
|
||||
const rawUrl =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
||||
normalizeMiniMaxBaseURL(rawUrl)
|
||||
|
||||
if (isAnthropicCompatible) {
|
||||
const minimax = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
} else {
|
||||
const minimax = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
""
|
||||
|
||||
if (!baseURL) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: `No base URL configured for provider: ${provider}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
// The Test button checks the user's own provider. On the server's
|
||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||
if (
|
||||
usesServerCredentials(provider, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
vertexApiKey,
|
||||
})
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Make a minimal test request
|
||||
const startTime = Date.now()
|
||||
await generateText({
|
||||
model,
|
||||
prompt: "Say 'OK'",
|
||||
maxOutputTokens: 20,
|
||||
// On the deployment's own endpoints a Test runs a model as a chat
|
||||
// does, so with the quota on it counts as a chat request (an
|
||||
// admin's Test of the server's URL does not)
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!serverUrl &&
|
||||
userId !== "anonymous" &&
|
||||
(await usesServerEndpoint(
|
||||
provider,
|
||||
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||
apiKey,
|
||||
))
|
||||
) {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: quotaCheck.error },
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The same model the chat would use. A client base URL makes it
|
||||
// refuse redirects to internal hosts.
|
||||
const { model } = getAIModel({
|
||||
provider,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
trustedBaseUrl: serverUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
// Temporary AWS credentials need it, as in the chat
|
||||
awsSessionToken,
|
||||
vertexApiKey,
|
||||
// EdgeOne checks the Pages cookies and the access code
|
||||
...(provider === "edgeone" && {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Streaming, like the chat (some models only stream). Stop at the
|
||||
// first tool call; a reasoning model that runs out of tokens first
|
||||
// proves the connection but not tool support.
|
||||
const startTime = Date.now()
|
||||
const result = streamText({
|
||||
model,
|
||||
prompt: "Call the ping tool.",
|
||||
tools: { ping: PING_TOOL },
|
||||
maxOutputTokens: 1024,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
})
|
||||
let calledTool = false
|
||||
let finishReason: string | undefined
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
// The timeout ends the stream with an abort part, not an error
|
||||
if (part.type === "abort") {
|
||||
const timeout = new Error(
|
||||
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||
)
|
||||
timeout.name = "TimeoutError"
|
||||
throw timeout
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
calledTool = true
|
||||
break
|
||||
}
|
||||
if (part.type === "finish") finishReason = part.finishReason
|
||||
}
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
...(!calledTool &&
|
||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
let errorMessage = "Validation failed"
|
||||
if (error instanceof Error) {
|
||||
// Extract meaningful error message
|
||||
if (
|
||||
error.message.includes("401") ||
|
||||
error.message.includes("Unauthorized")
|
||||
) {
|
||||
errorMessage = "Invalid API key"
|
||||
} else if (
|
||||
error.message.includes("404") ||
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
errorMessage = "Model not found"
|
||||
} else if (
|
||||
error.message.includes("429") ||
|
||||
error.message.includes("rate limit")
|
||||
) {
|
||||
errorMessage = "Rate limited - try again later"
|
||||
} else if (error.message.includes("ECONNREFUSED")) {
|
||||
errorMessage = "Cannot connect to server"
|
||||
} else {
|
||||
errorMessage = error.message.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const { code, message } = classifyLLMError(error)
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: errorMessage },
|
||||
{ valid: false, code, error: message },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
|
||||
+6
-1
@@ -7,7 +7,12 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -642,9 +642,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
dict.save.savedSuccessfully,
|
||||
)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
||||
/>
|
||||
{onUrlChange && (
|
||||
<UrlInputDialog
|
||||
|
||||
+176
-249
@@ -26,6 +26,7 @@ import {
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
@@ -37,12 +38,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
replaceNodes,
|
||||
validateAndFixXml,
|
||||
} from "@/lib/utils"
|
||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
// Helper to extract complete operations from streaming input
|
||||
function getCompleteOperations(
|
||||
@@ -148,6 +149,8 @@ interface SessionMetadata {
|
||||
|
||||
interface ChatMessageDisplayProps {
|
||||
messages: UIMessage[]
|
||||
// Shown on an error that a model setting can fix (bad key, unknown model)
|
||||
onOpenModelConfig?: () => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||
@@ -171,6 +174,7 @@ interface ChatMessageDisplayProps {
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
messages,
|
||||
onOpenModelConfig,
|
||||
setInput,
|
||||
setFiles,
|
||||
processedToolCallsRef,
|
||||
@@ -190,7 +194,24 @@ export function ChatMessageDisplay({
|
||||
currentInput = "",
|
||||
}: ChatMessageDisplayProps) {
|
||||
const dict = useDictionary()
|
||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||
// The thinking header in the page language
|
||||
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
|
||||
}
|
||||
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
|
||||
return (
|
||||
<p>
|
||||
{duration === 1
|
||||
? dict.reasoning.thoughtForOne
|
||||
: dict.reasoning.thoughtFor.replace(
|
||||
"{duration}",
|
||||
String(duration),
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||
const previousXML = useRef<string>("")
|
||||
@@ -210,20 +231,6 @@ export function ChatMessageDisplay({
|
||||
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
||||
}
|
||||
}, [messages.length, processedToolCalls])
|
||||
// Debounce streaming diagram updates - store pending XML and timeout
|
||||
const pendingXmlRef = useRef<string | null>(null)
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
)
|
||||
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
|
||||
// Refs for edit_diagram streaming
|
||||
const pendingEditRef = useRef<{
|
||||
operations: DiagramOperation[]
|
||||
toolCallId: string
|
||||
} | null>(null)
|
||||
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
)
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
@@ -345,73 +352,30 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming preview of display_diagram: draw the complete cells written
|
||||
// so far. The tool handler validates and loads the final diagram.
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string, showToast = false) => {
|
||||
let currentXml = xml || ""
|
||||
(xml: string) => {
|
||||
const completeCells = extractCompleteMxCells(xml || "")
|
||||
if (!completeCells) return
|
||||
const convertedXml = convertToLegalXml(completeCells)
|
||||
if (convertedXml === previousXML.current) return
|
||||
|
||||
// During streaming (showToast=false), extract only complete mxCell elements
|
||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||
if (!showToast) {
|
||||
const completeCells = extractCompleteMxCells(currentXml)
|
||||
if (!completeCells) {
|
||||
return
|
||||
}
|
||||
currentXml = completeCells
|
||||
}
|
||||
// Skip this update while the cells written so far don't parse
|
||||
const testDoc = new DOMParser().parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
if (testDoc.querySelector("parsererror")) return
|
||||
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
if (convertedXml !== previousXML.current) {
|
||||
// Parse and validate XML BEFORE calling replaceNodes
|
||||
const parser = new DOMParser()
|
||||
// Wrap in root element for parsing multiple mxCell elements
|
||||
const testDoc = parser.parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
const parseError = testDoc.querySelector("parsererror")
|
||||
|
||||
if (parseError) {
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(dict.errors.malformedXml)
|
||||
}
|
||||
return // Skip this update
|
||||
}
|
||||
|
||||
try {
|
||||
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
||||
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
||||
const baseXML =
|
||||
chartXML ||
|
||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
|
||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||
// The quick DOM parse check above catches malformed XML
|
||||
// Full validation runs on final output (showToast=true)
|
||||
if (!showToast) {
|
||||
previousXML.current = convertedXml
|
||||
onDisplayChart(replacedXML, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Final output: run full validation and auto-fix
|
||||
const validation = validateAndFixXml(replacedXML)
|
||||
if (validation.valid) {
|
||||
previousXML.current = convertedXml
|
||||
// Use fixed XML if available, otherwise use original
|
||||
const xmlToLoad = validation.fixed || replacedXML
|
||||
onDisplayChart(xmlToLoad, true)
|
||||
} else {
|
||||
toast.error(dict.errors.validationFailed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(dict.errors.failedToProcess)
|
||||
}
|
||||
}
|
||||
try {
|
||||
// An empty canvas gets a default mxfile to put the cells in
|
||||
const baseXML = chartXML || BLANK_MXFILE
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
previousXML.current = convertedXml
|
||||
onDisplayChart(replacedXML, true)
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
}
|
||||
},
|
||||
[chartXML, onDisplayChart],
|
||||
@@ -458,6 +422,12 @@ export function ChatMessageDisplay({
|
||||
// Previous messages are already processed and won't change
|
||||
const messagesToProcess =
|
||||
messages.length > 0 ? [messages[messages.length - 1]] : []
|
||||
// The diagram without streamed previews, as loaded last: the tool
|
||||
// handler's result of an earlier edit is there before the chartXML
|
||||
// state catches up. Undoing a failed edit's preview below changes it
|
||||
// too, and an edit streaming right after must start from the undone
|
||||
// diagram.
|
||||
let baseXml = chartXMLRef.current
|
||||
|
||||
messagesToProcess.forEach((message) => {
|
||||
// Messages restored from a saved session were applied before it was
|
||||
@@ -485,101 +455,84 @@ export function ChatMessageDisplay({
|
||||
if (isRestoredMessage) return
|
||||
|
||||
if (
|
||||
part.type === "tool-display_diagram" &&
|
||||
input?.xml
|
||||
part.type !== "tool-display_diagram" &&
|
||||
part.type !== "tool-edit_diagram"
|
||||
) {
|
||||
const xml = input.xml as string
|
||||
return
|
||||
}
|
||||
|
||||
// Failed or stopped: if the original XML is still
|
||||
// stored, the tool handler never ran (invalid JSON,
|
||||
// or the user pressed stop), so undo the streamed
|
||||
// preview here. Invalid JSON leaves no input, so
|
||||
// check this first.
|
||||
if (state === "output-error") {
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCallId,
|
||||
)
|
||||
onDisplayChart(originalXml, true)
|
||||
baseXml = originalXml
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Input complete, or the tool handler, a stop or an
|
||||
// error took the call already: the tool handler loads
|
||||
// the checked diagram (with the original XML). The
|
||||
// messages update at most every 150 ms (useChat
|
||||
// throttle in chat-panel), so they can still show the
|
||||
// call streaming after that.
|
||||
if (
|
||||
state !== "input-streaming" ||
|
||||
processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(
|
||||
`${toolCallId}-opCount`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "tool-display_diagram") {
|
||||
const xml = input?.xml as string | undefined
|
||||
// Skip if XML hasn't changed since last processing
|
||||
const lastXml =
|
||||
lastProcessedXmlRef.current.get(toolCallId)
|
||||
if (lastXml === xml) {
|
||||
return // Skip redundant processing
|
||||
}
|
||||
|
||||
if (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available"
|
||||
!xml ||
|
||||
lastProcessedXmlRef.current.get(toolCallId) ===
|
||||
xml
|
||||
) {
|
||||
// Debounce streaming updates - queue the XML and process after delay
|
||||
pendingXmlRef.current = xml
|
||||
|
||||
if (!debounceTimeoutRef.current) {
|
||||
// No pending timeout - set one up
|
||||
debounceTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
const pendingXml =
|
||||
pendingXmlRef.current
|
||||
debounceTimeoutRef.current = null
|
||||
pendingXmlRef.current = null
|
||||
if (pendingXml) {
|
||||
handleDisplayChart(
|
||||
pendingXml,
|
||||
false,
|
||||
)
|
||||
lastProcessedXmlRef.current.set(
|
||||
toolCallId,
|
||||
pendingXml,
|
||||
)
|
||||
}
|
||||
},
|
||||
STREAMING_DEBOUNCE_MS,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
state === "output-available" &&
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
// Final output - process immediately (clear any pending debounce)
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current)
|
||||
debounceTimeoutRef.current = null
|
||||
pendingXmlRef.current = null
|
||||
}
|
||||
// Show toast only if final XML is malformed
|
||||
handleDisplayChart(xml, true)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
// Clean up the ref entry - tool is complete, no longer needed
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
return
|
||||
}
|
||||
// Keep the diagram from before the preview, to
|
||||
// undo it on a stop or an error
|
||||
if (
|
||||
!editDiagramOriginalXmlRef.current.has(
|
||||
toolCallId,
|
||||
)
|
||||
) {
|
||||
editDiagramOriginalXmlRef.current.set(
|
||||
toolCallId,
|
||||
baseXml || BLANK_MXFILE,
|
||||
)
|
||||
}
|
||||
handleDisplayChart(xml)
|
||||
lastProcessedXmlRef.current.set(toolCallId, xml)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle edit_diagram streaming - apply operations incrementally for preview
|
||||
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
|
||||
if (
|
||||
part.type === "tool-edit_diagram" &&
|
||||
input?.operations
|
||||
) {
|
||||
// Failed or stopped: drop the queued preview. If the original
|
||||
// XML is still stored, the tool handler never ran (user pressed
|
||||
// stop), so undo the streamed preview here.
|
||||
if (state === "output-error") {
|
||||
if (
|
||||
pendingEditRef.current?.toolCallId ===
|
||||
toolCallId &&
|
||||
editDebounceTimeoutRef.current
|
||||
) {
|
||||
clearTimeout(editDebounceTimeoutRef.current)
|
||||
editDebounceTimeoutRef.current = null
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCallId,
|
||||
)
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "tool-edit_diagram") {
|
||||
if (!input?.operations) return
|
||||
const completeOps = getCompleteOperations(
|
||||
input.operations as DiagramOperation[],
|
||||
)
|
||||
|
||||
if (completeOps.length === 0) return
|
||||
|
||||
// Capture original XML when streaming starts (store in shared ref)
|
||||
@@ -588,7 +541,7 @@ export function ChatMessageDisplay({
|
||||
toolCallId,
|
||||
)
|
||||
) {
|
||||
if (!chartXML) {
|
||||
if (!baseXml) {
|
||||
console.warn(
|
||||
"[edit_diagram streaming] No chart XML available",
|
||||
)
|
||||
@@ -596,10 +549,9 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.set(
|
||||
toolCallId,
|
||||
chartXML,
|
||||
baseXml,
|
||||
)
|
||||
}
|
||||
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
@@ -607,96 +559,37 @@ export function ChatMessageDisplay({
|
||||
if (!originalXml) return
|
||||
|
||||
// Skip if no change from last processed state
|
||||
const lastCount = lastProcessedXmlRef.current.get(
|
||||
toolCallId + "-opCount",
|
||||
)
|
||||
if (lastCount === String(completeOps.length)) return
|
||||
|
||||
const countKey = `${toolCallId}-opCount`
|
||||
const opCount = String(completeOps.length)
|
||||
if (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available"
|
||||
lastProcessedXmlRef.current.get(countKey) ===
|
||||
opCount
|
||||
) {
|
||||
// Queue the operations for debounced processing
|
||||
pendingEditRef.current = {
|
||||
operations: completeOps,
|
||||
toolCallId,
|
||||
}
|
||||
|
||||
if (!editDebounceTimeoutRef.current) {
|
||||
editDebounceTimeoutRef.current = setTimeout(
|
||||
() => {
|
||||
const pending =
|
||||
pendingEditRef.current
|
||||
editDebounceTimeoutRef.current =
|
||||
null
|
||||
pendingEditRef.current = null
|
||||
|
||||
if (pending) {
|
||||
const origXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
pending.toolCallId,
|
||||
)
|
||||
if (!origXml) return
|
||||
|
||||
try {
|
||||
const {
|
||||
result: editedXml,
|
||||
} = applyDiagramOperations(
|
||||
origXml,
|
||||
pending.operations,
|
||||
)
|
||||
// Load the full document so other pages stay intact
|
||||
onDisplayChart(
|
||||
editedXml,
|
||||
true,
|
||||
)
|
||||
lastProcessedXmlRef.current.set(
|
||||
pending.toolCallId +
|
||||
"-opCount",
|
||||
String(
|
||||
pending.operations
|
||||
.length,
|
||||
),
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[edit_diagram streaming] Operation failed:`,
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: e,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
STREAMING_DEBOUNCE_MS,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
state === "output-available" &&
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
// Final state - cleanup streaming refs (tool handler does final application)
|
||||
if (editDebounceTimeoutRef.current) {
|
||||
clearTimeout(editDebounceTimeoutRef.current)
|
||||
editDebounceTimeoutRef.current = null
|
||||
}
|
||||
lastProcessedXmlRef.current.delete(
|
||||
toolCallId + "-opCount",
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { result } = applyDiagramOperations(
|
||||
originalXml,
|
||||
completeOps,
|
||||
)
|
||||
// Load the full document so other pages stay intact
|
||||
onDisplayChart(result, true)
|
||||
lastProcessedXmlRef.current.set(
|
||||
countKey,
|
||||
opCount,
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[edit_diagram streaming] Operation failed:",
|
||||
e instanceof Error ? e.message : e,
|
||||
)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Don't cleanup debounce timeouts here!
|
||||
// The cleanup runs on every re-render (when messages changes),
|
||||
// which would cancel the timeout before it fires.
|
||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||
}, [messages, handleDisplayChart, chartXML])
|
||||
}, [messages, handleDisplayChart, chartXMLRef])
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
@@ -881,7 +774,11 @@ export function ChatMessageDisplay({
|
||||
!isRestoredMessage
|
||||
}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningTrigger
|
||||
getThinkingMessage={
|
||||
thinkingMessage
|
||||
}
|
||||
/>
|
||||
<ReasoningContent>
|
||||
{
|
||||
reasoningPart.text
|
||||
@@ -987,8 +884,12 @@ export function ChatMessageDisplay({
|
||||
part.type?.startsWith(
|
||||
"tool-",
|
||||
)
|
||||
// Blank text (some models send
|
||||
// a lone space) gets no bubble
|
||||
const isContentPart =
|
||||
part.type === "text" ||
|
||||
(part.type === "text" &&
|
||||
part.text.trim() !==
|
||||
"") ||
|
||||
part.type === "file"
|
||||
|
||||
if (isToolPart) {
|
||||
@@ -1338,6 +1239,32 @@ export function ChatMessageDisplay({
|
||||
return null
|
||||
},
|
||||
)}
|
||||
{message.role ===
|
||||
"system" &&
|
||||
(
|
||||
message.metadata as
|
||||
| {
|
||||
openModelConfig?: boolean
|
||||
}
|
||||
| undefined
|
||||
)
|
||||
?.openModelConfig &&
|
||||
onOpenModelConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
onOpenModelConfig
|
||||
}
|
||||
className="mt-2 text-xs font-medium underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.errors
|
||||
.llm
|
||||
.openModelSettings
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
+270
-167
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai"
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
PanelRightClose,
|
||||
@@ -32,7 +32,6 @@ import { useSessionManager } from "@/hooks/use-session-manager"
|
||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { isMinimalDiagram } from "@/lib/chat-helpers"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
@@ -41,7 +40,9 @@ import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { UrlData } from "@/lib/url-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { cn, formatXML, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
||||
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import type { ValidationState } from "./chat/ValidationCard"
|
||||
import {
|
||||
APPENDED_FILE_SECTIONS_PATTERN,
|
||||
@@ -55,21 +56,6 @@ const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
||||
// sessionStorage keys
|
||||
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
|
||||
|
||||
// Type for message parts (tool calls and their states)
|
||||
interface MessagePart {
|
||||
type: string
|
||||
state?: string
|
||||
toolName?: string
|
||||
input?: { xml?: string; [key: string]: unknown }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: string
|
||||
parts?: MessagePart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean
|
||||
onToggleVisibility: () => void
|
||||
@@ -92,22 +78,10 @@ const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retr
|
||||
* Check if auto-resubmit should happen based on tool errors.
|
||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||
*/
|
||||
function hasToolErrors(messages: ChatMessage[]): boolean {
|
||||
function hasToolErrors(messages: UIMessage[]): boolean {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage || lastMessage.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolParts =
|
||||
(lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
|
||||
part.type?.startsWith("tool-"),
|
||||
) || []
|
||||
|
||||
if (toolParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastToolPart = toolParts[toolParts.length - 1]
|
||||
if (lastMessage?.role !== "assistant") return false
|
||||
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
||||
return lastToolPart?.state === TOOL_ERROR_STATE
|
||||
}
|
||||
|
||||
@@ -136,8 +110,9 @@ export default function ChatPanel({
|
||||
loadDiagram: onDisplayChart,
|
||||
handleExport: onExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
exportResolversRef,
|
||||
chartXML,
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
latestSvg,
|
||||
clearDiagram,
|
||||
getThumbnailSvg,
|
||||
@@ -153,21 +128,15 @@ export default function ChatPanel({
|
||||
const urlSessionId = searchParams.get("session")
|
||||
|
||||
const onFetchChart = (saveToHistory = true) => {
|
||||
// Waits for the reply to its own export, by its tag
|
||||
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
|
||||
return Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
if (saveToHistory) {
|
||||
onExport()
|
||||
} else {
|
||||
handleExportWithoutHistory()
|
||||
}
|
||||
if (tag) exportResolversRef.current[tag] = resolve
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
const currentResolver = resolverRef.current
|
||||
setTimeout(() => {
|
||||
if (resolverRef.current === currentResolver) {
|
||||
resolverRef.current = null
|
||||
}
|
||||
delete exportResolversRef.current[tag]
|
||||
reject(new Error("Chart export timed out after 10 seconds"))
|
||||
}, 10000)
|
||||
}),
|
||||
@@ -306,8 +275,16 @@ export default function ChatPanel({
|
||||
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
|
||||
const processedToolCallsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
|
||||
// Key: toolCallId, Value: original XML before any operations applied
|
||||
// Set by Stop until the user sends the next message
|
||||
const stoppedRef = useRef(false)
|
||||
const preparingSendRef = useRef(false)
|
||||
// Presses of Stop: a check that began before one still knows of it after
|
||||
// the next message clears stoppedRef
|
||||
const stopCountRef = useRef(0)
|
||||
|
||||
// Store original XML for display_diagram and edit_diagram streaming -
|
||||
// shared between streaming preview and tool handler
|
||||
// Key: toolCallId, Value: XML before the call's preview was drawn
|
||||
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Debounce timeout for localStorage writes (prevents blocking during streaming)
|
||||
@@ -356,14 +333,17 @@ export default function ChatPanel({
|
||||
const validationRetryCountRef = useRef(0)
|
||||
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
const { validateWithFallback, cancel: cancelValidation } =
|
||||
useValidateDiagram()
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
// A preview undone just before the tool call is in this one already
|
||||
chartXMLRef: liveChartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
@@ -371,6 +351,11 @@ export default function ChatPanel({
|
||||
validateDiagram: validateWithFallback,
|
||||
enableVlmValidation: vlmValidationEnabled,
|
||||
sessionId,
|
||||
watchStop: () => {
|
||||
const stopsBefore = stopCountRef.current
|
||||
return () =>
|
||||
stoppedRef.current || stopCountRef.current !== stopsBefore
|
||||
},
|
||||
onValidationStateChange: handleValidationStateChange,
|
||||
})
|
||||
|
||||
@@ -389,96 +374,104 @@ export default function ChatPanel({
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
onFinish: ({ message, isAbort, isError }) => {
|
||||
// Stopped or failed: tool calls still streaming never reach the
|
||||
// tool handler. Mark them handled so a later render of the
|
||||
// stream does not draw their preview again.
|
||||
if (!isAbort && !isError) return
|
||||
for (const part of message.parts as any[]) {
|
||||
if (part.state === "input-streaming" && part.toolCallId) {
|
||||
processedToolCallsRef.current.add(part.toolCallId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Handle server-side quota limit (429 response)
|
||||
// AI SDK puts the full response body in error.message for non-OK responses
|
||||
// A diagram still streaming when the request failed never
|
||||
// reaches the tool handler: undo its preview. Only previews not
|
||||
// handled yet are stored, and the first one holds the diagram
|
||||
// before any of them.
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
|
||||
// Server errors are JSON: a quota limit ({type: request, token or
|
||||
// tpm}), a provider error ({type: "provider", code, message}) or
|
||||
// {error}. The SDK puts the response body in error.message.
|
||||
let data: any = null
|
||||
try {
|
||||
const data = JSON.parse(error.message)
|
||||
if (data.type === "request") {
|
||||
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (data.type === "token") {
|
||||
quotaManager.showTokenLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (data.type === "tpm") {
|
||||
quotaManager.showTPMLimitToast(data.limit)
|
||||
return
|
||||
}
|
||||
data = JSON.parse(error.message)
|
||||
} catch {
|
||||
// Not JSON, fall through to string matching for backwards compatibility
|
||||
// Plain text, e.g. a network failure in the browser
|
||||
}
|
||||
|
||||
// Fallback to string matching
|
||||
if (error.message.includes("Daily request limit")) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
if (data?.type === "request") {
|
||||
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (error.message.includes("Daily token limit")) {
|
||||
quotaManager.showTokenLimitToast()
|
||||
if (data?.type === "token") {
|
||||
quotaManager.showTokenLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (
|
||||
error.message.includes("Rate limit exceeded") ||
|
||||
error.message.includes("tokens per minute")
|
||||
) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
if (data?.type === "tpm") {
|
||||
quotaManager.showTPMLimitToast(data.limit)
|
||||
return
|
||||
}
|
||||
|
||||
const isAccessCodeError = String(
|
||||
data?.error ?? error.message,
|
||||
).includes("Invalid or missing access code")
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
}
|
||||
if (!isAccessCodeError) console.error("Chat error:", error)
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
// The server now handles detailed error messages, so we can display them directly.
|
||||
// But we still handle connection/network errors that happen before reaching the server.
|
||||
let friendlyMessage = error.message
|
||||
|
||||
// Simple check for network errors if message is generic
|
||||
if (friendlyMessage === "Failed to fetch") {
|
||||
friendlyMessage = "Network error. Please check your connection."
|
||||
}
|
||||
|
||||
// Truncated tool input error (model output limit too low)
|
||||
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
||||
friendlyMessage =
|
||||
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
||||
}
|
||||
|
||||
// Translate image not supported error
|
||||
if (
|
||||
friendlyMessage.includes("image content block") ||
|
||||
friendlyMessage.toLowerCase().includes("image_url")
|
||||
) {
|
||||
friendlyMessage = "This model doesn't support image input."
|
||||
// A hint the user can act on, then the provider's own words
|
||||
let text: string = error.message
|
||||
let openModelConfig = false
|
||||
if (data?.type === "provider") {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
text = hints[data.code]
|
||||
? `${hints[data.code]}\n\n${data.message}`
|
||||
: data.message
|
||||
openModelConfig = [
|
||||
"invalid_api_key",
|
||||
"forbidden",
|
||||
"model_not_found",
|
||||
].includes(data.code)
|
||||
} else if (typeof data?.error === "string") {
|
||||
text = data.error
|
||||
} else if (error.message === "Failed to fetch") {
|
||||
text = dict.errors.networkError
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
setMessages((currentMessages) => [
|
||||
...currentMessages,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: friendlyMessage,
|
||||
parts: [{ type: "text" as const, text: friendlyMessage }],
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
content: text,
|
||||
parts: [{ type: "text" as const, text }],
|
||||
// The message shows a button that opens model settings
|
||||
...(openModelConfig && {
|
||||
metadata: { openModelConfig: true },
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
if (isAccessCodeError) {
|
||||
// Show settings dialog to help user fix it
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
onFinish: () => {},
|
||||
// Re-render streamed messages at most every 150 ms. The streaming
|
||||
// diagram preview draws on each update, so this also limits redraws
|
||||
experimental_throttle: 150,
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
// The user stopped: a tool result that arrives later (a VLM
|
||||
// check still running) must not start a new request
|
||||
if (stoppedRef.current) return false
|
||||
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
const shouldRetry = hasToolErrors(
|
||||
messages as unknown as ChatMessage[],
|
||||
)
|
||||
const shouldRetry = hasToolErrors(messages)
|
||||
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
@@ -532,9 +525,10 @@ export default function ChatPanel({
|
||||
|
||||
// Track last synced session ID to detect external changes (e.g., URL back/forward)
|
||||
const lastSyncedSessionIdRef = useRef<string | null>(null)
|
||||
// Messages array from our latest save. A session holding this exact array was
|
||||
// created by our own save, so it must not be treated as an external switch.
|
||||
const lastSavedMessagesRef = useRef<unknown[] | null>(null)
|
||||
// Message arrays of our own saves. A session holding one of them was
|
||||
// created by our own save, so it must not be treated as an external
|
||||
// switch (with two saves of a new chat at once, the first creates it).
|
||||
const savedMessagesRef = useRef(new WeakSet<object>())
|
||||
|
||||
// Helper: Sync UI state with session data (eliminates duplication)
|
||||
// Track message IDs that are being loaded from session (to skip animations/scroll)
|
||||
@@ -602,7 +596,7 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
const messages = sanitizeMessages(messagesRef.current)
|
||||
lastSavedMessagesRef.current = messages
|
||||
savedMessagesRef.current.add(messages)
|
||||
return {
|
||||
messages,
|
||||
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
|
||||
@@ -659,7 +653,7 @@ export default function ChatPanel({
|
||||
|
||||
// Our own save created this session; the UI already shows its content
|
||||
const isOwnNewSession =
|
||||
newSession?.messages === lastSavedMessagesRef.current
|
||||
!!newSession && savedMessagesRef.current.has(newSession.messages)
|
||||
|
||||
// Update last synced ID
|
||||
lastSyncedSessionIdRef.current = newSessionId
|
||||
@@ -686,6 +680,8 @@ export default function ChatPanel({
|
||||
isAvailable: sessionIsAvailable,
|
||||
currentSessionId,
|
||||
saveCurrentSession,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
} = sessionManager
|
||||
|
||||
// Use ref for saveCurrentSession to avoid infinite loop
|
||||
@@ -710,26 +706,30 @@ export default function ChatPanel({
|
||||
clearTimeout(localStorageDebounceRef.current)
|
||||
}
|
||||
|
||||
// Capture current session ID at schedule time to verify at save time
|
||||
const scheduledForSessionId = currentSessionId
|
||||
// Capture the chat on screen at schedule time; the save is dropped
|
||||
// if another chat is on screen by the time it runs
|
||||
const scheduledForChat = getChatGeneration()
|
||||
// Capture whether there's a REAL diagram NOW (not just empty template)
|
||||
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
|
||||
// Check if this session was just loaded without a diagram
|
||||
const isNodiagramSession =
|
||||
justLoadedSessionIdRef.current === scheduledForSessionId
|
||||
justLoadedSessionIdRef.current === currentSessionId
|
||||
|
||||
// Debounce: save after 1 second of no changes
|
||||
localStorageDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
if (messages.length > 0 || hasDiagramNow) {
|
||||
// Taken before the data is read, for the chat it was
|
||||
// scheduled for
|
||||
const ticket = {
|
||||
...getSaveTicket(),
|
||||
generation: scheduledForChat,
|
||||
}
|
||||
const sessionData = await buildSessionData({
|
||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||
})
|
||||
await saveCurrentSessionRef.current(
|
||||
sessionData,
|
||||
scheduledForSessionId,
|
||||
)
|
||||
await saveCurrentSessionRef.current(sessionData, ticket)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error)
|
||||
@@ -748,6 +748,8 @@ export default function ChatPanel({
|
||||
status,
|
||||
sessionIsAvailable,
|
||||
currentSessionId,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
buildSessionData,
|
||||
])
|
||||
|
||||
@@ -780,10 +782,11 @@ export default function ChatPanel({
|
||||
try {
|
||||
// Attempt to save session - browser may not wait for completion
|
||||
// Skip thumbnail capture as it may not complete in time
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: false,
|
||||
})
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
await sessionManager.saveCurrentSession(sessionData, ticket)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save session on visibility change:",
|
||||
@@ -801,8 +804,7 @@ export default function ChatPanel({
|
||||
)
|
||||
}, [sessionManager, buildSessionData])
|
||||
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const submitInput = async () => {
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
// Attachments still extracting have no text yet. Template sends call
|
||||
// requestSubmit() and skip the disabled send button, so check here too.
|
||||
@@ -812,10 +814,7 @@ export default function ChatPanel({
|
||||
if (input.trim() && !isProcessing && !isExtracting) {
|
||||
// Check if input matches a cached example (only when no messages
|
||||
// yet and the canvas is empty, same rule as the server)
|
||||
if (
|
||||
messages.length === 0 &&
|
||||
isMinimalDiagram(chartXMLRef.current || "")
|
||||
) {
|
||||
if (messages.length === 0 && !hasCells(chartXMLRef.current || "")) {
|
||||
// Pass the file name so a user's own file never matches an example
|
||||
const cached = findCachedResponse(
|
||||
input.trim(),
|
||||
@@ -823,8 +822,9 @@ export default function ChatPanel({
|
||||
files.length === 1 ? files[0].name : undefined,
|
||||
)
|
||||
if (cached) {
|
||||
// Add user message and fake assistant response to messages
|
||||
// The chat-message-display useEffect will handle displaying the diagram
|
||||
// Add the user message and a finished display_diagram
|
||||
// answer, and load its diagram here: these messages never
|
||||
// reach the tool handler
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
|
||||
// Build user message text including any file content
|
||||
@@ -859,8 +859,13 @@ export default function ChatPanel({
|
||||
// Snapshot the canvas before the example so editing this message works
|
||||
xmlSnapshotsRef.current.set(
|
||||
0,
|
||||
chartXMLRef.current || wrapWithMxFile(""),
|
||||
chartXMLRef.current || BLANK_MXFILE,
|
||||
)
|
||||
const prepared = prepareNewDiagram(cached.xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
})
|
||||
if (prepared.ok) onDisplayChart(prepared.xml, true)
|
||||
setInput("")
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
setFiles([])
|
||||
@@ -884,13 +889,12 @@ export default function ChatPanel({
|
||||
// Add the combined text as the first part
|
||||
parts.unshift({ type: "text", text: userText })
|
||||
|
||||
await sendWithCurrentDiagram(parts)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
setInput("")
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
setFiles([])
|
||||
setUrlData(new Map())
|
||||
await sendWithCurrentDiagram(parts, () => {
|
||||
setInput("")
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
setFiles([])
|
||||
setUrlData(new Map())
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error)
|
||||
toast.error(dict.errors.failedToExport)
|
||||
@@ -898,8 +902,27 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Export the current diagram, snapshot it for this message, and send
|
||||
const sendWithCurrentDiagram = async (parts: any[]) => {
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
// While a send is prepared (attachments read, diagram exported) the
|
||||
// status is still "ready": a second Enter or click would send the
|
||||
// message again
|
||||
if (preparingSendRef.current) return
|
||||
preparingSendRef.current = true
|
||||
try {
|
||||
await submitInput()
|
||||
} finally {
|
||||
preparingSendRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Export the current diagram, snapshot it for this message, and send.
|
||||
// onSent runs right after sending, so the input empties as the message
|
||||
// shows in the chat
|
||||
const sendWithCurrentDiagram = async (
|
||||
parts: any[],
|
||||
onSent?: () => void,
|
||||
) => {
|
||||
const chartXml = formatXML(await onFetchChart())
|
||||
const previousXml = getPreviousXml(messages.length)
|
||||
|
||||
@@ -911,6 +934,7 @@ export default function ChatPanel({
|
||||
)
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
onSent?.()
|
||||
}
|
||||
|
||||
// Send VLM validation feedback as a new user message through the normal send path
|
||||
@@ -924,22 +948,43 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
// The current chat could not be saved (storage full). The list where
|
||||
// old chats can be deleted shows only in an empty chat, so let the user
|
||||
// go on without saving. It replaces the plain message, and has its own
|
||||
// id so a later failed auto-save does not take its button away.
|
||||
const offerToContinueUnsaved = useCallback(
|
||||
(proceed: () => void) => {
|
||||
toast.dismiss("session-save-failed")
|
||||
toast.error(dict.errors.sessionSaveFailedLeave, {
|
||||
id: "session-save-leave",
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: dict.errors.continueWithoutSaving,
|
||||
onClick: proceed,
|
||||
},
|
||||
})
|
||||
},
|
||||
[dict],
|
||||
)
|
||||
|
||||
// A new turn makes the offer stale: going on would clear the chat while
|
||||
// the answer streams in
|
||||
useEffect(() => {
|
||||
if (status === "submitted" || status === "streaming") {
|
||||
toast.dismiss("session-save-leave")
|
||||
}
|
||||
}, [status])
|
||||
|
||||
// Handle session switching from history dropdown
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (!sessionManager.isAvailable) return
|
||||
|
||||
// Save current session before switching
|
||||
if (messages.length > 0) {
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
}
|
||||
|
||||
// Switch to selected session
|
||||
const sessionData = await sessionManager.switchSession(sessionId)
|
||||
if (sessionData) {
|
||||
const open = async () => {
|
||||
const sessionData =
|
||||
await sessionManager.switchSession(sessionId)
|
||||
if (!sessionData) return
|
||||
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
|
||||
justLoadedSessionRef.current = true
|
||||
|
||||
@@ -957,8 +1002,37 @@ export default function ChatPanel({
|
||||
syncUIWithSession(sessionData)
|
||||
router.replace(`?session=${sessionId}`, { scroll: false })
|
||||
}
|
||||
|
||||
// Save current session before switching (also a diagram drawn
|
||||
// without messages); if that failed (storage full), stay on it
|
||||
// unless the user goes on without saving it
|
||||
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
|
||||
// Of the chat on screen now, also if another one comes on
|
||||
// screen while the thumbnail is taken
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
const sessionData = await buildSessionData({
|
||||
withThumbnail: true,
|
||||
})
|
||||
if (
|
||||
!(await sessionManager.saveCurrentSession(
|
||||
sessionData,
|
||||
ticket,
|
||||
))
|
||||
) {
|
||||
offerToContinueUnsaved(open)
|
||||
return
|
||||
}
|
||||
}
|
||||
await open()
|
||||
},
|
||||
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
|
||||
[
|
||||
sessionManager,
|
||||
messages,
|
||||
buildSessionData,
|
||||
syncUIWithSession,
|
||||
router,
|
||||
offerToContinueUnsaved,
|
||||
],
|
||||
)
|
||||
|
||||
// Handle session deletion from history dropdown
|
||||
@@ -976,15 +1050,7 @@ export default function ChatPanel({
|
||||
[sessionManager, syncUIWithSession, router, pathname],
|
||||
)
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save current session before creating new one
|
||||
if (sessionManager.isAvailable && messages.length > 0) {
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
await sessionManager.saveCurrentSession(sessionData)
|
||||
// Refresh sessions list to ensure dropdown shows the saved session
|
||||
await sessionManager.refreshSessions()
|
||||
}
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
// Clear session manager state BEFORE clearing URL to prevent race condition
|
||||
// (otherwise the URL update effect would restore the old session URL)
|
||||
sessionManager.clearCurrentSession()
|
||||
@@ -1016,14 +1082,41 @@ export default function ChatPanel({
|
||||
setMessages,
|
||||
setSessionId,
|
||||
sessionManager,
|
||||
messages,
|
||||
router,
|
||||
dict.dialogs.clearSuccess,
|
||||
buildSessionData,
|
||||
setDiagramHistory,
|
||||
pathname,
|
||||
])
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
// Save current session before creating new one (also a diagram
|
||||
// drawn without messages)
|
||||
if (
|
||||
sessionManager.isAvailable &&
|
||||
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
|
||||
) {
|
||||
const ticket = sessionManager.getSaveTicket()
|
||||
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||
// Not saved (storage full): keep the chat on screen, unless the
|
||||
// user goes on without saving it
|
||||
if (
|
||||
!(await sessionManager.saveCurrentSession(sessionData, ticket))
|
||||
) {
|
||||
offerToContinueUnsaved(startNewChat)
|
||||
return
|
||||
}
|
||||
// Refresh sessions list to ensure dropdown shows the saved session
|
||||
await sessionManager.refreshSessions()
|
||||
}
|
||||
startNewChat()
|
||||
}, [
|
||||
sessionManager,
|
||||
messages,
|
||||
buildSessionData,
|
||||
offerToContinueUnsaved,
|
||||
startNewChat,
|
||||
])
|
||||
|
||||
// Handle sending a template directly (called from TemplatePanel)
|
||||
const handleSendTemplate = useCallback(
|
||||
async (template: { prompt: string }) => {
|
||||
@@ -1083,11 +1176,19 @@ export default function ChatPanel({
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
stopCountRef.current++
|
||||
// A running screenshot check holds up the chat (the SDK waits for
|
||||
// the tool handler): end it, so the call gets its result now
|
||||
cancelValidation()
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
// Calls the tool handler already took can still show as streaming:
|
||||
// the messages update at most every 150 ms (useChat throttle)
|
||||
const toolParts = lastMessage?.parts?.filter(
|
||||
(part: any) =>
|
||||
part.type?.startsWith("tool-") &&
|
||||
part.state === "input-streaming",
|
||||
part.state === "input-streaming" &&
|
||||
!processedToolCallsRef.current.has(part.toolCallId),
|
||||
)
|
||||
|
||||
toolParts?.forEach((part: any) => {
|
||||
@@ -1102,7 +1203,7 @@ export default function ChatPanel({
|
||||
})
|
||||
|
||||
stop()
|
||||
}, [messages, addToolOutput, stop])
|
||||
}, [messages, addToolOutput, stop, cancelValidation])
|
||||
|
||||
// Send chat message with headers
|
||||
const sendChatMessage = (
|
||||
@@ -1116,6 +1217,7 @@ export default function ChatPanel({
|
||||
continuationRetryCountRef.current = 0
|
||||
validationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
stoppedRef.current = false
|
||||
|
||||
const config = getSelectedAIConfig()
|
||||
|
||||
@@ -1448,6 +1550,7 @@ export default function ChatPanel({
|
||||
{/* Messages */}
|
||||
<main className="flex-1 w-full overflow-hidden">
|
||||
<ChatMessageDisplay
|
||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||
messages={messages}
|
||||
setInput={setInput}
|
||||
setFiles={handleFileChange}
|
||||
|
||||
@@ -224,7 +224,8 @@ export function TemplatePanel({
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
@@ -20,11 +20,17 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
// Streamed or invalid input can hold anything: show only what React can
|
||||
// render (an object in place of a string would crash the whole chat)
|
||||
const shown = operations.filter(
|
||||
(op) => typeof (op as { operation?: unknown })?.operation === "string",
|
||||
)
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : "")
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
{shown.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
key={`${op.operation}-${text(op.cell_id)}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
cell_id: {text(op.cell_id)}
|
||||
</span>
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
{text(op.new_xml) && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
{text(op.new_xml)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -93,12 +99,14 @@ export function ToolCallCard({
|
||||
}
|
||||
|
||||
// Incomplete XML means the output hit the length limit, unless the user
|
||||
// stopped the generation themselves
|
||||
// stopped the generation themselves. Without an input the JSON was
|
||||
// broken (the server repairs JSON cut short by the limit).
|
||||
const isTruncated =
|
||||
state === "output-error" &&
|
||||
errorText !== "Stopped by user" &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
typeof input?.xml === "string" &&
|
||||
!isMxCellXmlComplete(input.xml)
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
|
||||
export type { DiagramOperation }
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
const prepared = prepareNewDiagram(xml)
|
||||
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
+430
-199
@@ -4,12 +4,12 @@ import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Key,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
@@ -34,6 +34,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -43,6 +50,11 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
@@ -56,8 +68,13 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import type { ListedModel } from "@/lib/provider-models"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||
import type {
|
||||
ModelConfig,
|
||||
ProviderConfig,
|
||||
ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -126,9 +143,10 @@ export function ModelConfigDialog({
|
||||
> | null>(null)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
||||
const [validatingModelIndex, setValidatingModelIndex] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
// Models whose test is running (they are all tested at once)
|
||||
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
)
|
||||
const [duplicateError, setDuplicateError] = useState<string>("")
|
||||
const [editError, setEditError] = useState<{
|
||||
modelId: string
|
||||
@@ -139,17 +157,17 @@ export function ModelConfigDialog({
|
||||
id: string
|
||||
value: string
|
||||
} | null>(null)
|
||||
// Bumped on every credential edit so a running test can tell that its
|
||||
// results belong to the old credentials
|
||||
const credentialsVersionRef = useRef(0)
|
||||
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
|
||||
Partial<Record<ProviderName, string[]>>
|
||||
// Models fetched from the provider, per provider config
|
||||
const [fetchedModels, setFetchedModels] = useState<
|
||||
Record<string, ListedModel[]>
|
||||
>({})
|
||||
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
|
||||
Partial<Record<ProviderName, boolean>>
|
||||
>({})
|
||||
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
|
||||
useState<ProviderName | null>(null)
|
||||
const [fetchingModels, setFetchingModels] = useState(false)
|
||||
const [fetchModelsError, setFetchModelsError] = useState("")
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false)
|
||||
// models.dev data for hints, loaded with the dialog (it is ~180 KB)
|
||||
const [getModelInfo, setGetModelInfo] = useState<
|
||||
typeof import("@/lib/model-catalog").getModelInfo | null
|
||||
>(null)
|
||||
|
||||
const {
|
||||
config,
|
||||
@@ -165,6 +183,30 @@ export function ModelConfigDialog({
|
||||
const selectedProvider = config.providers.find(
|
||||
(p) => p.id === selectedProviderId,
|
||||
)
|
||||
// For requests that finish after the user switched provider or edited
|
||||
// a model id
|
||||
const selectedProviderIdRef = useRef(selectedProviderId)
|
||||
selectedProviderIdRef.current = selectedProviderId
|
||||
const configRef = useRef(config)
|
||||
configRef.current = config
|
||||
// Number of the latest Test click: only that test may reset the busy
|
||||
// state when its credentials changed meanwhile
|
||||
const validationRunRef = useRef(0)
|
||||
// A model list or test result belongs to the credentials it was asked
|
||||
// with; they can change meanwhile, here or in another tab
|
||||
const credentialsOf = (providerId: string) => {
|
||||
const p = configRef.current.providers.find((x) => x.id === providerId)
|
||||
return JSON.stringify([
|
||||
p?.provider,
|
||||
p?.apiKey,
|
||||
p?.baseUrl,
|
||||
p?.awsAccessKeyId,
|
||||
p?.awsSecretAccessKey,
|
||||
p?.awsRegion,
|
||||
p?.awsSessionToken,
|
||||
p?.vertexApiKey,
|
||||
])
|
||||
}
|
||||
|
||||
// Discard an unfinished model ID edit when the dialog closes
|
||||
useEffect(() => {
|
||||
@@ -181,73 +223,83 @@ export function ModelConfigDialog({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
selectedProvider?.provider !== "aihubmix" ||
|
||||
loadedSuggestedProviders.aihubmix
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!open || getModelInfo) return
|
||||
import("@/lib/model-catalog").then((catalog) =>
|
||||
setGetModelInfo(() => catalog.getModelInfo),
|
||||
)
|
||||
}, [open, getModelInfo])
|
||||
|
||||
let cancelled = false
|
||||
setLoadingSuggestedProvider("aihubmix")
|
||||
|
||||
fetch(getApiEndpoint("/api/aihubmix-models"))
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load models: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then((data: { models?: unknown }) => {
|
||||
if (cancelled || !Array.isArray(data.models)) {
|
||||
return
|
||||
}
|
||||
|
||||
const models = data.models.filter(
|
||||
(model): model is string => typeof model === "string",
|
||||
)
|
||||
if (models.length > 0) {
|
||||
setDynamicSuggestedModels((current) => ({
|
||||
...current,
|
||||
aihubmix: models,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to load AIHubMix models:", error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadedSuggestedProviders((current) => ({
|
||||
const handleFetchModels = async () => {
|
||||
if (!selectedProvider) return
|
||||
const providerId = selectedProvider.id
|
||||
const askedWith = credentialsOf(providerId)
|
||||
setFetchingModels(true)
|
||||
setFetchModelsError("")
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/provider-models"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: selectedProvider.provider,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
baseUrl: selectedProvider.baseUrl,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (credentialsOf(providerId) !== askedWith) return
|
||||
// The picker and the error belong to the provider shown
|
||||
const stillShown = selectedProviderIdRef.current === providerId
|
||||
if (Array.isArray(data.models)) {
|
||||
setFetchedModels((current) => ({
|
||||
...current,
|
||||
aihubmix: true,
|
||||
[providerId]: data.models,
|
||||
}))
|
||||
setLoadingSuggestedProvider(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (stillShown) setModelPickerOpen(true)
|
||||
} else if (stillShown) {
|
||||
const hints = dict.errors.llm as Record<string, string>
|
||||
setFetchModelsError(
|
||||
[hints[data.code], data.error].filter(Boolean).join(" ") ||
|
||||
`Request failed (${response.status})`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
selectedProviderIdRef.current === providerId &&
|
||||
credentialsOf(providerId) === askedWith
|
||||
) {
|
||||
setFetchModelsError(dict.errors.networkError)
|
||||
}
|
||||
} finally {
|
||||
setFetchingModels(false)
|
||||
}
|
||||
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
|
||||
}
|
||||
|
||||
// Get suggested models for current provider
|
||||
const suggestedModels = selectedProvider
|
||||
? dynamicSuggestedModels[selectedProvider.provider] ||
|
||||
SUGGESTED_MODELS[selectedProvider.provider] ||
|
||||
[]
|
||||
// The provider's own list once fetched, else the suggested models
|
||||
const suggestedModels: ListedModel[] = selectedProvider
|
||||
? fetchedModels[selectedProvider.id] ||
|
||||
(SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
|
||||
id,
|
||||
}))
|
||||
: []
|
||||
const isLoadingSuggestedModels =
|
||||
selectedProvider?.provider === loadingSuggestedProvider
|
||||
// Tool calls are what drawing needs: false when known to be missing
|
||||
const supportsTools = (model: ListedModel) =>
|
||||
selectedProvider
|
||||
? (model.tools ??
|
||||
getModelInfo?.(selectedProvider.provider, model.id)?.tools)
|
||||
: undefined
|
||||
|
||||
// Filter out already-added models from suggestions
|
||||
const existingModelIds =
|
||||
selectedProvider?.models.map((m) => m.modelId) || []
|
||||
const availableSuggestions = suggestedModels.filter(
|
||||
(modelId) => !existingModelIds.includes(modelId),
|
||||
(model) => !existingModelIds.includes(model.id),
|
||||
)
|
||||
const emptyStateSuggestions = selectedProvider
|
||||
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
|
||||
@@ -260,6 +312,8 @@ export function ModelConfigDialog({
|
||||
const newProvider = addProvider(providerType)
|
||||
setSelectedProviderId(newProvider.id)
|
||||
setValidationStatus("idle")
|
||||
setFetchModelsError("")
|
||||
setModelPickerOpen(false)
|
||||
}
|
||||
|
||||
// Handle provider field updates
|
||||
@@ -279,14 +333,17 @@ export function ModelConfigDialog({
|
||||
"vertexApiKey",
|
||||
]
|
||||
if (credentialFields.includes(field)) {
|
||||
credentialsVersionRef.current++
|
||||
setValidationStatus("idle")
|
||||
setValidatingModelIndex(null)
|
||||
setValidatingModelIds(new Set())
|
||||
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
|
||||
setFetchModelsError("")
|
||||
updates.validated = false
|
||||
updates.models = selectedProvider.models.map((m) => ({
|
||||
...m,
|
||||
validated: undefined,
|
||||
validationError: undefined,
|
||||
validationWarning: undefined,
|
||||
responseTime: undefined,
|
||||
}))
|
||||
}
|
||||
updateProvider(selectedProviderId, updates)
|
||||
@@ -359,94 +416,164 @@ export function ModelConfigDialog({
|
||||
|
||||
let allValid = true
|
||||
let errorCount = 0
|
||||
const credentialsVersion = credentialsVersionRef.current
|
||||
let idChanged = false
|
||||
const askedWith = credentialsOf(selectedProviderId)
|
||||
const run = ++validationRunRef.current
|
||||
|
||||
// Validate each model
|
||||
for (let i = 0; i < selectedProvider.models.length; i++) {
|
||||
const model = selectedProvider.models[i]
|
||||
setValidatingModelIndex(i)
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
const baseUrl = isEdgeOne
|
||||
? `${window.location.origin}/api/edgeai`
|
||||
: selectedProvider.baseUrl
|
||||
|
||||
try {
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
const baseUrl = isEdgeOne
|
||||
? `${window.location.origin}${getApiEndpoint("/api/edgeai")}`
|
||||
: selectedProvider.baseUrl
|
||||
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/validate-model"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) ||
|
||||
"",
|
||||
// Test every model at once; each row updates when its answer arrives
|
||||
setValidatingModelIds(new Set(selectedProvider.models.map((m) => m.id)))
|
||||
await Promise.all(
|
||||
selectedProvider.models.map(async (model) => {
|
||||
let update: Partial<ModelConfig>
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/validate-model"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(
|
||||
STORAGE_KEYS.accessCode,
|
||||
) || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: selectedProvider.provider,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
baseUrl,
|
||||
modelId: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||
awsSecretAccessKey:
|
||||
selectedProvider.awsSecretAccessKey,
|
||||
awsRegion: selectedProvider.awsRegion,
|
||||
// Temporary AWS credentials, as the chat sends
|
||||
awsSessionToken:
|
||||
selectedProvider.awsSessionToken,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: selectedProvider.vertexApiKey,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: selectedProvider.provider,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
baseUrl,
|
||||
modelId: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||
awsSecretAccessKey:
|
||||
selectedProvider.awsSecretAccessKey,
|
||||
awsRegion: selectedProvider.awsRegion,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: selectedProvider.vertexApiKey,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
// Credentials changed during the test: drop the results
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
|
||||
if (data.valid) {
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
validated: true,
|
||||
validationError: undefined,
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
update = data.valid
|
||||
? {
|
||||
validated: true,
|
||||
validationError: undefined,
|
||||
validationWarning: data.warning,
|
||||
responseTime: data.responseTime,
|
||||
}
|
||||
: {
|
||||
validated: false,
|
||||
// The hint for the error's kind, then the
|
||||
// provider's own message
|
||||
validationError:
|
||||
[
|
||||
(
|
||||
dict.errors.llm as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
)[data.code],
|
||||
data.error,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") ||
|
||||
(response.ok
|
||||
? "Validation failed"
|
||||
: `Request failed (${response.status})`),
|
||||
validationWarning: undefined,
|
||||
}
|
||||
} catch {
|
||||
update = {
|
||||
validated: false,
|
||||
validationError: "Network error",
|
||||
validationWarning: undefined,
|
||||
}
|
||||
}
|
||||
// A newer test started: its own results and spinners count,
|
||||
// whatever the credentials are now (they may have come back)
|
||||
if (run !== validationRunRef.current) return
|
||||
// Credentials changed during the test: drop the result. A
|
||||
// change in another tab left the spinner on, so clear it
|
||||
// (model ids are unique, whatever provider is shown).
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) {
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// So did this model's id: the result is for the old one
|
||||
const current = configRef.current.providers
|
||||
.find((p) => p.id === selectedProviderId)
|
||||
?.models.find((m) => m.id === model.id)
|
||||
if (current?.modelId !== model.modelId) {
|
||||
idChanged = true
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
if (update.validated === false) {
|
||||
allValid = false
|
||||
errorCount++
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
validated: false,
|
||||
validationError:
|
||||
data.error ||
|
||||
(response.ok
|
||||
? "Validation failed"
|
||||
: `Request failed (${response.status})`),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
allValid = false
|
||||
errorCount++
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
validated: false,
|
||||
validationError: "Network error",
|
||||
updateModel(selectedProviderId, model.id, update)
|
||||
setValidatingModelIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(model.id)
|
||||
return next
|
||||
})
|
||||
}),
|
||||
)
|
||||
if (run !== validationRunRef.current) return
|
||||
if (credentialsOf(selectedProviderId) !== askedWith) {
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current === selectedProviderId) {
|
||||
setValidationStatus("idle")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setValidatingModelIndex(null)
|
||||
|
||||
if (allValid) {
|
||||
setValidationStatus("success")
|
||||
// A model whose id changed was not tested
|
||||
if (allValid && !idChanged) {
|
||||
updateProvider(selectedProviderId, { validated: true })
|
||||
}
|
||||
// The status line is about the provider shown now
|
||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||
if (idChanged) {
|
||||
setValidationStatus("idle")
|
||||
} else if (allValid) {
|
||||
setValidationStatus("success")
|
||||
// Reset to idle after showing success briefly (with cleanup)
|
||||
if (validationResetTimeoutRef.current) {
|
||||
clearTimeout(validationResetTimeoutRef.current)
|
||||
}
|
||||
validationResetTimeoutRef.current = setTimeout(() => {
|
||||
setValidationStatus("idle")
|
||||
validationResetTimeoutRef.current = null
|
||||
if (run !== validationRunRef.current) return
|
||||
setValidationStatus("idle")
|
||||
}, 1500)
|
||||
} else {
|
||||
setValidationStatus("error")
|
||||
setValidationError(`${errorCount} model(s) failed validation`)
|
||||
}
|
||||
}, [selectedProvider, selectedProviderId, updateProvider, updateModel])
|
||||
}, [
|
||||
selectedProvider,
|
||||
selectedProviderId,
|
||||
updateProvider,
|
||||
updateModel,
|
||||
dict,
|
||||
])
|
||||
|
||||
// Get all available provider types
|
||||
const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[]
|
||||
@@ -602,6 +729,10 @@ export function ModelConfigDialog({
|
||||
)
|
||||
setValidationStatus("idle")
|
||||
setShowApiKey(false)
|
||||
// These belong to the
|
||||
// provider shown before
|
||||
setFetchModelsError("")
|
||||
setModelPickerOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
|
||||
@@ -877,58 +1008,136 @@ export function ModelConfigDialog({
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleAddModel(
|
||||
value,
|
||||
)
|
||||
{PROVIDER_INFO[
|
||||
selectedProvider.provider
|
||||
].modelList && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg"
|
||||
onClick={
|
||||
handleFetchModels
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isLoadingSuggestedModels ||
|
||||
availableSuggestions.length ===
|
||||
0
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
||||
{isLoadingSuggestedModels ? (
|
||||
disabled={
|
||||
fetchingModels
|
||||
}
|
||||
title={
|
||||
dict.modelConfig
|
||||
.fetchModels
|
||||
}
|
||||
aria-label={
|
||||
dict.modelConfig
|
||||
.fetchModels
|
||||
}
|
||||
>
|
||||
{fetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<span className="text-xs">
|
||||
{availableSuggestions.length ===
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* modal: the dialog blocks the
|
||||
wheel outside itself, and the
|
||||
list is rendered outside it */}
|
||||
<Popover
|
||||
modal
|
||||
open={modelPickerOpen}
|
||||
onOpenChange={
|
||||
setModelPickerOpen
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-28 h-8 rounded-lg text-xs"
|
||||
disabled={
|
||||
availableSuggestions.length ===
|
||||
0
|
||||
? dict
|
||||
.modelConfig
|
||||
.allAdded
|
||||
: dict
|
||||
.modelConfig
|
||||
.suggested}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{availableSuggestions.map(
|
||||
(modelId) => (
|
||||
<SelectItem
|
||||
key={
|
||||
modelId
|
||||
}
|
||||
>
|
||||
{availableSuggestions.length ===
|
||||
0
|
||||
? dict
|
||||
.modelConfig
|
||||
.allAdded
|
||||
: dict
|
||||
.modelConfig
|
||||
.suggested}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0"
|
||||
align="end"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={
|
||||
dict
|
||||
.modelConfig
|
||||
.searchModels
|
||||
}
|
||||
/>
|
||||
<CommandList className="max-h-72">
|
||||
<CommandEmpty>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.noModelsFound
|
||||
}
|
||||
value={
|
||||
modelId
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{modelId}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CommandEmpty>
|
||||
{availableSuggestions.map(
|
||||
(model) => (
|
||||
<CommandItem
|
||||
key={
|
||||
model.id
|
||||
}
|
||||
value={
|
||||
model.id
|
||||
}
|
||||
onSelect={() => {
|
||||
handleAddModel(
|
||||
model.id,
|
||||
)
|
||||
setModelPickerOpen(
|
||||
false,
|
||||
)
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{
|
||||
model.id
|
||||
}
|
||||
</span>
|
||||
{supportsTools(
|
||||
model,
|
||||
) ===
|
||||
false && (
|
||||
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.noTools
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
),
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fetchModelsError && (
|
||||
<p className="mb-2 text-xs text-destructive">
|
||||
{fetchModelsError}
|
||||
</p>
|
||||
)}
|
||||
{/* Model List */}
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
||||
{selectedProvider.models.length ===
|
||||
@@ -982,7 +1191,7 @@ export function ModelConfigDialog({
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{selectedProvider.models.map(
|
||||
(model, index) => (
|
||||
(model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={cn(
|
||||
@@ -992,28 +1201,24 @@ export function ModelConfigDialog({
|
||||
<div className="flex items-center gap-3 p-3 min-w-0">
|
||||
{/* Status icon */}
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
|
||||
{validatingModelIndex !==
|
||||
null &&
|
||||
index ===
|
||||
validatingModelIndex ? (
|
||||
{validatingModelIds.has(
|
||||
model.id,
|
||||
) ? (
|
||||
// Currently validating
|
||||
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : validatingModelIndex !==
|
||||
null &&
|
||||
index >
|
||||
validatingModelIndex &&
|
||||
model.validated ===
|
||||
undefined ? (
|
||||
// Queued
|
||||
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
true ? (
|
||||
// Valid
|
||||
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
|
||||
// Valid, with the time the test took
|
||||
<div
|
||||
className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"
|
||||
title={
|
||||
model.responseTime
|
||||
? `${(model.responseTime / 1000).toFixed(1)} s`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
@@ -1189,6 +1394,10 @@ export function ModelConfigDialog({
|
||||
undefined,
|
||||
validationError:
|
||||
undefined,
|
||||
validationWarning:
|
||||
undefined,
|
||||
responseTime:
|
||||
undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1219,6 +1428,28 @@ export function ModelConfigDialog({
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{!model.validationWarning &&
|
||||
getModelInfo?.(
|
||||
selectedProvider.provider,
|
||||
model.modelId,
|
||||
)?.tools ===
|
||||
false && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.mayNotDraw
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{model.validated &&
|
||||
model.validationWarning && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
|
||||
{
|
||||
model.validationWarning
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{/* Show edit error inline */}
|
||||
{editError?.modelId ===
|
||||
model.id && (
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
@@ -26,7 +31,7 @@ export type SecretField =
|
||||
| "vertexApiKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
const AWS_REGIONS: Array<[string, string]> = [
|
||||
export const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
["us-east-2", "Ohio"],
|
||||
["us-west-2", "Oregon"],
|
||||
@@ -77,6 +82,7 @@ export function ProviderCredentialsFields({
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
@@ -208,17 +214,29 @@ export function ProviderCredentialsFields({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
{/* API Key, with a link to where the provider issues keys */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
@@ -238,12 +256,28 @@ export function ProviderCredentialsFields({
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
// Drop a pasted endpoint path such as /chat/completions
|
||||
onBlur={(e) => {
|
||||
const normalized = normalizeBaseUrl(
|
||||
e.target.value,
|
||||
)
|
||||
if (normalized !== e.target.value) {
|
||||
onChange("baseUrl", normalized)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{requestUrl && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
|
||||
+157
-143
@@ -1,26 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
extractDiagramXML,
|
||||
isRealDiagram,
|
||||
validateAndFixXml,
|
||||
} from "../lib/utils"
|
||||
BLANK_MXFILE,
|
||||
normalizeToMxfile,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||
chartXMLRef: React.MutableRefObject<string>
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
// Both return the export's tag (empty when draw.io is not there yet)
|
||||
handleExport: () => string
|
||||
handleExportWithoutHistory: () => string
|
||||
// Pending exports by tag; a history or plain export's resolver gets the
|
||||
// first page's XML
|
||||
exportResolversRef: React.MutableRefObject<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
@@ -42,10 +50,10 @@ interface DiagramContextType {
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
// Exports for thumbnails, validation PNGs and file saves carry a tag in the
|
||||
// request's `message` field. draw.io echoes the request back in the export
|
||||
// event, so each result reaches its own caller; untagged exports (chat-panel's
|
||||
// onFetchChart) resolve resolverRef.
|
||||
// Every export carries a tag in the request's `message` field. draw.io
|
||||
// echoes the request back in the export event, so each result reaches its
|
||||
// own caller. Tags end in a request number, so a late result never answers
|
||||
// a newer request.
|
||||
type ExportTag = "thumbnail" | "validation"
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -58,13 +66,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Pending thumbnail and validation PNG exports, keyed by their export tag
|
||||
const taggedResolversRef = useRef<
|
||||
Partial<Record<ExportTag, (value: string) => void>>
|
||||
// Pending exports, keyed by their export tag
|
||||
const exportResolversRef = useRef<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>({})
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Pending history exports: the document each one was asked for
|
||||
const historyXmlRef = useRef(new Map<string, string>())
|
||||
const exportSeqRef = useRef(0)
|
||||
// Track latest chartXML for restoration after remount
|
||||
const chartXMLRef = useRef<string>("")
|
||||
|
||||
@@ -91,54 +99,55 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
setChartXML(xml)
|
||||
}
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
const handleExport = () => {
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
if (!drawioRef.current) return ""
|
||||
// Save this export to history, with the document shown now:
|
||||
// chartXML can change before the result comes back
|
||||
const tag = `history-${++exportSeqRef.current}`
|
||||
historyXmlRef.current.set(tag, chartXMLRef.current)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
message: tag,
|
||||
})
|
||||
return tag
|
||||
}
|
||||
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
if (!drawioRef.current) return ""
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
const tag = `fetch-${++exportSeqRef.current}`
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
message: tag,
|
||||
})
|
||||
return tag
|
||||
}
|
||||
|
||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||
// event) and wait for that result. Resolves to null on timeout, which is
|
||||
// expected occasionally.
|
||||
const requestTaggedExport = (
|
||||
tag: ExportTag,
|
||||
format: "xmlsvg" | "png",
|
||||
timeoutMs: number,
|
||||
) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const finish = (value: string | null) => {
|
||||
clearTimeout(timer)
|
||||
if (taggedResolversRef.current[tag] === finish) {
|
||||
delete taggedResolversRef.current[tag]
|
||||
// (Reads refs only, so it keeps one identity)
|
||||
const requestTaggedExport = useCallback(
|
||||
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const id = `${tag}-${++exportSeqRef.current}`
|
||||
const finish = (value: string | null) => {
|
||||
clearTimeout(timer)
|
||||
delete exportResolversRef.current[id]
|
||||
resolve(value)
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
taggedResolversRef.current[tag] = finish
|
||||
drawioRef.current?.exportDiagram({ format, message: tag })
|
||||
})
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
exportResolversRef.current[id] = finish
|
||||
drawioRef.current?.exportDiagram({ format, message: id })
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||
// renders this provider again (latestSvg), which would otherwise start
|
||||
// the next save
|
||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
@@ -151,7 +160,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
}
|
||||
}, [requestTaggedExport])
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
const captureValidationPng = async (): Promise<string | null> => {
|
||||
@@ -170,9 +179,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
): string | null => {
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||
// tool handlers check model XML strictly before it gets here.
|
||||
if (!skipValidation) {
|
||||
const validation = validateAndFixXml(chart)
|
||||
const validation = validateAndFixXml(chart, { strict: false })
|
||||
if (!validation.valid) {
|
||||
console.warn(
|
||||
"[loadDiagram] Validation error:",
|
||||
@@ -203,16 +214,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Tagged exports (thumbnail, validation PNG, file save) go only to
|
||||
// their own caller, so they never take the result meant for resolverRef
|
||||
// Thumbnail, validation PNG and file save exports go only to their
|
||||
// own caller
|
||||
const tag = data.message?.message
|
||||
if (tag === "thumbnail" || tag === "validation") {
|
||||
taggedResolversRef.current[tag]?.(data.data)
|
||||
return
|
||||
}
|
||||
if (tag === "save") {
|
||||
saveResolverRef.current.resolver?.(data.data, data.xml)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) {
|
||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -226,10 +232,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Only add to history if this was a user-initiated export
|
||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||
const MAX_HISTORY_SIZE = 20
|
||||
if (expectHistoryExportRef.current) {
|
||||
const askedXml =
|
||||
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
|
||||
if (askedXml !== undefined) {
|
||||
historyXmlRef.current.delete(tag as string)
|
||||
// Store the full multi-page document (extractedXML is only the
|
||||
// first page), so restoring a version keeps every page
|
||||
const historyXml = chartXMLRef.current || extractedXML
|
||||
const historyXml = askedXml || extractedXML
|
||||
setDiagramHistory((prev) => {
|
||||
const newHistory = [
|
||||
...prev,
|
||||
@@ -241,12 +250,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||
})
|
||||
expectHistoryExportRef.current = false
|
||||
}
|
||||
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML)
|
||||
resolverRef.current = null
|
||||
// The chat's own export (onFetchChart), not another one in flight
|
||||
const resolve =
|
||||
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||
if (resolve) {
|
||||
delete exportResolversRef.current[tag as string]
|
||||
resolve(extractedXML)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +274,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const emptyDiagram = BLANK_MXFILE
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
setLatestSvg("")
|
||||
@@ -285,85 +296,87 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
// Each save has its own tag, so two at once never swap results
|
||||
const tag = `save-${++exportSeqRef.current}`
|
||||
exportResolversRef.current[tag] = (
|
||||
exportData: string,
|
||||
fullDiagramXML?: string,
|
||||
) => {
|
||||
delete exportResolversRef.current[tag]
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
fileContent =
|
||||
normalizeToMxfile(xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
}) ?? xml
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
},
|
||||
format,
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({
|
||||
format: drawioFormat,
|
||||
message: "save",
|
||||
message: tag,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,13 +401,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
<DiagramContext.Provider
|
||||
value={{
|
||||
chartXML,
|
||||
chartXMLRef,
|
||||
latestSvg,
|
||||
diagramHistory,
|
||||
setDiagramHistory,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
exportResolversRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
|
||||
@@ -124,6 +124,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
|
||||
图表会实时显示在浏览器中!
|
||||
|
||||
MCP服务器包含网页版的大部分画图功能:
|
||||
|
||||
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
||||
- 截图工具,AI可以查看画好的图并自行修正
|
||||
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
||||
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
||||
|
||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -149,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
|
||||
|
||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||
|
||||
### OpenRouter
|
||||
|
||||
@@ -164,9 +164,11 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
|
||||
|
||||
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
@@ -122,6 +122,13 @@ Claudeにダイアグラムの作成を依頼:
|
||||
|
||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||
|
||||
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
||||
|
||||
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
||||
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
||||
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
||||
|
||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||
|
||||
## はじめに
|
||||
|
||||
@@ -149,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
|
||||
|
||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||
|
||||
### OpenRouter
|
||||
|
||||
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
||||
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
||||
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
||||
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
|
||||
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
||||
|
||||
## Networking & Infrastructure
|
||||
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
|---------|--------|--------|-------------|------|
|
||||
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
||||
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
||||
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
|
||||
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
||||
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
||||
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
||||
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
||||
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
|
||||
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
||||
|
||||
## General Diagrams
|
||||
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
||||
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
|
||||
|
||||
## Enterprise Software
|
||||
|
||||
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
||||
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
|
||||
|
||||
**Total: 33 libraries, 4,281 shapes**
|
||||
**Total: 30 libraries, 4,184 shapes**
|
||||
|
||||
@@ -97,11 +97,13 @@ function hasValidAccessCode(request: Request, env: any): boolean {
|
||||
|
||||
export async function onRequest({ request, env }: any) {
|
||||
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||
// preflight, which fails without CORS headers
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
!request.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
// preflight, which fails without CORS headers. Only the type before any
|
||||
// parameters counts: "text/plain; x=application/json" needs none.
|
||||
const mediaType = (request.headers.get("content-type") ?? "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (request.method !== "POST" || mediaType !== "application/json") {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
|
||||
Vendored
+9
-4
@@ -60,10 +60,6 @@ declare global {
|
||||
maximize: () => void
|
||||
/** Close the window */
|
||||
close: () => void
|
||||
/** Open file dialog and return file path */
|
||||
openFile: () => Promise<string | null>
|
||||
/** Save data to file via save dialog */
|
||||
saveFile: (data: string) => Promise<boolean>
|
||||
/** Get proxy configuration */
|
||||
getProxy: () => Promise<ProxyConfig>
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
@@ -74,6 +70,15 @@ declare global {
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
/**
|
||||
* Call back after the server restarted on the same port (another
|
||||
* preset); returns a function that stops the calls
|
||||
*/
|
||||
onServerRestarted?: (callback: () => void) => () => void
|
||||
/** A chat was saved: open this port next launch */
|
||||
chatSaved?: () => Promise<void>
|
||||
/** The page loaded with this many chats */
|
||||
chatsLoaded?: (count: number) => Promise<void>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
|
||||
@@ -32,6 +32,9 @@ export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
// Number of the latest preset switch
|
||||
let lastSwitch = 0
|
||||
|
||||
/**
|
||||
* Apply a preset and restart the server so it takes effect.
|
||||
* If the restart fails, go back to the previous preset and restart again,
|
||||
@@ -46,6 +49,7 @@ export async function switchPreset(
|
||||
if (!env) {
|
||||
throw new Error("Preset not found")
|
||||
}
|
||||
const switchNumber = ++lastSwitch
|
||||
rebuildAppMenu()
|
||||
|
||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||
@@ -60,6 +64,15 @@ export async function switchPreset(
|
||||
console.error("Failed to restart server:", error)
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// A newer switch started meanwhile (also of this same preset), or
|
||||
// the preset was deleted: its own restart follows, and undoing
|
||||
// would lose that choice
|
||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||
setCurrentPreset(null)
|
||||
|
||||
@@ -159,6 +159,10 @@ function getConfigFilePath(): string {
|
||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
// The presets file exists but the last read failed: a save now would
|
||||
// replace the user's presets with the empty list that read returned
|
||||
let presetsUnreadable = false
|
||||
|
||||
/**
|
||||
* Load presets from the config file
|
||||
* Decrypts sensitive fields automatically
|
||||
@@ -167,6 +171,25 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
const configPath = getConfigFilePath()
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
// Nothing left that a save could overwrite
|
||||
presetsUnreadable = false
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(configPath, "utf-8")
|
||||
presetsUnreadable = false
|
||||
} catch (error) {
|
||||
// Often only for now (on Windows an antivirus scanner can hold the
|
||||
// file): keep the file, and refuse saves based on this empty list
|
||||
console.error("Failed to read config presets:", error)
|
||||
presetsUnreadable = true
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
@@ -176,7 +199,6 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const data = JSON.parse(content) as ConfigPresetsFile
|
||||
|
||||
// Decrypt sensitive fields in each preset
|
||||
@@ -195,7 +217,9 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
renameSync(configPath, backupPath)
|
||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||
} catch (renameError) {
|
||||
// Still there: refuse saves that would overwrite it
|
||||
console.error("Failed to back up config presets:", renameError)
|
||||
presetsUnreadable = true
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
@@ -211,6 +235,11 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
* Encrypts sensitive fields automatically
|
||||
*/
|
||||
export function savePresets(data: ConfigPresetsFile): void {
|
||||
if (presetsUnreadable) {
|
||||
throw new Error(
|
||||
"The presets file could not be read, so it was not overwritten. Please try again.",
|
||||
)
|
||||
}
|
||||
const configPath = getConfigFilePath()
|
||||
const userDataPath = app.getPath("userData")
|
||||
|
||||
|
||||
@@ -28,6 +28,25 @@ export function loadEnvFile(): void {
|
||||
console.log("No .env file found, using system environment variables")
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the quote that closes a value starting with a quote, or -1. A
|
||||
* backslash before the quote character escapes it, as in dotenv; the
|
||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||
* comment or nothing after it still closes the value when no other quote
|
||||
* does ("C:\dir\" keeps its trailing backslash).
|
||||
*/
|
||||
function findClosingQuote(value: string): number {
|
||||
const quote = value[0]
|
||||
let lastEscaped = -1
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||
i++
|
||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||
} else if (value[i] === quote) return i
|
||||
}
|
||||
return lastEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load environment variables from a file
|
||||
*/
|
||||
@@ -50,14 +69,26 @@ function loadEnvFromFile(filePath: string): void {
|
||||
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
|
||||
if (closingQuote > 0) {
|
||||
// Quoted value: keep what's inside the quotes and drop
|
||||
// anything after them (e.g. a comment)
|
||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||
) {
|
||||
// Quoted value, then nothing or a comment: keep what is
|
||||
// inside the quotes, as dotenv reads it
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment")
|
||||
// Unquoted value: drop an inline comment ("value # comment").
|
||||
// A value quoted from start to end with quotes inside (JSON
|
||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
value.length > 1 &&
|
||||
value.endsWith(quote)
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
|
||||
@@ -124,9 +124,11 @@ if (!gotTheLock) {
|
||||
})
|
||||
|
||||
// Clicking a plain link would otherwise replace the app page with
|
||||
// an external site that keeps the preload API
|
||||
// an external site that keeps the preload API. Only the page
|
||||
// itself may navigate there; draw.io stays in its frame (this event
|
||||
// is for the main frame only)
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isInAppUrl(event.url)) {
|
||||
if (isAppUrl(event.url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
type IpcMainInvokeEvent,
|
||||
ipcMain,
|
||||
} from "electron"
|
||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||
import {
|
||||
type ConfigPreset,
|
||||
@@ -19,6 +13,7 @@ import {
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||
import {
|
||||
applyProxyToEnv,
|
||||
getProxyConfig,
|
||||
@@ -83,6 +78,15 @@ export function registerIpcHandlers(): void {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// ==================== Where the chats are ====================
|
||||
|
||||
// The page saved a chat, or loaded without any: decides which port
|
||||
// (and so which origin's chats) the next launch opens
|
||||
handle("chat-saved", () => rememberChatPort())
|
||||
handle("chats-loaded", (_event, count: unknown) => {
|
||||
if (count === 0) noteNoChats()
|
||||
})
|
||||
|
||||
// ==================== Window Controls ====================
|
||||
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
@@ -104,60 +108,6 @@ export function registerIpcHandlers(): void {
|
||||
win?.close()
|
||||
})
|
||||
|
||||
// ==================== File Dialogs ====================
|
||||
|
||||
handle("dialog-open-file", async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return null
|
||||
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
console.error("Failed to read file:", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
handle("dialog-save-file", async (event, data: string) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || typeof data !== "string") return false
|
||||
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio"] },
|
||||
{ name: "XML Files", extensions: ["xml"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
await fs.writeFile(result.filePath, data, "utf-8")
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Config Presets ====================
|
||||
|
||||
handle("config-presets:get-all", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getAllocatedPort,
|
||||
getServerUrl,
|
||||
isPortAvailable,
|
||||
saveServerPort,
|
||||
} from "./port-manager"
|
||||
import { setAppUrl } from "./window-manager"
|
||||
|
||||
@@ -87,6 +86,8 @@ async function startServer(): Promise<string> {
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
// The preset keys are the user's own, not a server's
|
||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||
}
|
||||
|
||||
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
||||
@@ -143,7 +144,6 @@ async function startServer(): Promise<string> {
|
||||
const url = getServerUrl()
|
||||
await waitForServer(url)
|
||||
console.log(`Next.js server started at ${url}`)
|
||||
saveServerPort(port)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
@@ -26,37 +26,68 @@ const PORT_CONFIG = {
|
||||
let allocatedPort: number | null = null
|
||||
|
||||
/**
|
||||
* File that remembers the production port from the last launch, so the app
|
||||
* keeps the same origin (and its localStorage) instead of switching between
|
||||
* the legacy and new port depending on which one is free at startup
|
||||
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
|
||||
* each origin's IndexedDB in its own folder
|
||||
*/
|
||||
function getSavedPortPath(): string {
|
||||
return path.join(app.getPath("userData"), "server-port.json")
|
||||
function hasStoredData(port: number): boolean {
|
||||
return existsSync(
|
||||
path.join(
|
||||
app.getPath("userData"),
|
||||
"IndexedDB",
|
||||
`http_127.0.0.1_${port}.indexeddb.leveldb`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function loadSavedPort(): number | null {
|
||||
// The two fixed production ports, the only ones whose origin (and so its
|
||||
// chats and settings) is the same at every launch
|
||||
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
|
||||
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
||||
|
||||
/** The fixed port where a chat was last saved, if known */
|
||||
function readChatPort(): number | null {
|
||||
try {
|
||||
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
|
||||
return Number.isInteger(port) ? port : null
|
||||
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
||||
return HOME_PORTS.includes(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the port the production server started on
|
||||
*/
|
||||
export function saveServerPort(port: number): void {
|
||||
if (!app.isPackaged || port === loadSavedPort()) {
|
||||
return
|
||||
}
|
||||
function writeChatPort(port: number): void {
|
||||
try {
|
||||
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
|
||||
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
||||
} catch (error) {
|
||||
console.error("Failed to save server port:", error)
|
||||
console.warn("Could not save the chat port:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page saved a chat: open on this port next time. Chats of the two
|
||||
* ports cannot be shown together (each origin has its own storage), so the
|
||||
* app opens where the user last worked. A launch that had to use the other
|
||||
* port and saved nothing does not move it.
|
||||
*/
|
||||
export function rememberChatPort(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (readChatPort() !== port) writeChatPort(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page loaded without any chats. Before any chat was saved under this
|
||||
* version (no file yet), the user's chats may be on the other fixed port,
|
||||
* where an older version opened: try it first next time.
|
||||
*/
|
||||
export function noteNoChats(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (existsSync(chatPortFile())) return
|
||||
const other = HOME_PORTS.find((p) => p !== port)
|
||||
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
@@ -78,8 +109,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses the port from the last launch, then the legacy
|
||||
* port (61337), then 13370, to preserve localStorage
|
||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||
* localStorage; 13370 first when only it has saved chats
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
@@ -104,35 +135,26 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, use the port from the last launch first
|
||||
if (!isDev) {
|
||||
const savedPort = loadSavedPort()
|
||||
if (savedPort !== null) {
|
||||
if (await isPortAvailable(savedPort)) {
|
||||
allocatedPort = savedPort
|
||||
return savedPort
|
||||
}
|
||||
console.warn(
|
||||
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
|
||||
)
|
||||
// In production, first the port where a chat was last saved. Without
|
||||
// one, the legacy port first to preserve existing users' data, unless
|
||||
// only the new port has data: their app started on 13370 while Windows
|
||||
// reserved 61337, and 61337 being free now would hide it
|
||||
const chatPort = isDev ? null : readChatPort()
|
||||
const candidates = isDev
|
||||
? [preferredPort]
|
||||
: chatPort !== null
|
||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
for (const port of candidates) {
|
||||
if (await isPortAvailable(port)) {
|
||||
allocatedPort = port
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
// In production, try legacy port first to preserve existing users' localStorage
|
||||
if (!isDev) {
|
||||
const legacyPort = PORT_CONFIG.legacyProduction
|
||||
if (await isPortAvailable(legacyPort)) {
|
||||
allocatedPort = legacyPort
|
||||
return legacyPort
|
||||
}
|
||||
}
|
||||
|
||||
// Try preferred port
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Preferred port ${preferredPort} is in use, finding alternative...`,
|
||||
)
|
||||
|
||||
@@ -106,11 +106,14 @@ export function getAppUrl(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the main window at a new app server URL
|
||||
* (the restarted server can come up on a different port)
|
||||
* Point the main window at the restarted app server (it can come up on a
|
||||
* different port). On the same port the page fetches the new preset's
|
||||
* server models instead of sending the old preset's choice; it is not
|
||||
* reloaded, which would drop unsent attachments.
|
||||
*/
|
||||
export function setAppUrl(url: string): void {
|
||||
if (url === appUrl) {
|
||||
mainWindow?.webContents.send("server-restarted")
|
||||
return
|
||||
}
|
||||
appUrl = url
|
||||
|
||||
@@ -18,10 +18,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
maximize: () => ipcRenderer.send("window-maximize"),
|
||||
close: () => ipcRenderer.send("window-close"),
|
||||
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||
|
||||
// Proxy settings
|
||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||
@@ -31,4 +27,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
|
||||
// A chat was saved, or the page loaded with this many chats: the next
|
||||
// launch opens the port where the chats are
|
||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||
|
||||
// The server restarted on the same port (another preset)
|
||||
onServerRestarted: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on("server-restarted", listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener("server-restarted", listener)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
+7
-3
@@ -44,7 +44,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Google Generative AI Configuration
|
||||
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
||||
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
|
||||
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
||||
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
||||
@@ -71,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# AZURE_REASONING_SUMMARY=detailed
|
||||
|
||||
# Ollama Configuration (Local or Cloud)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434)
|
||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||
|
||||
@@ -124,9 +123,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
|
||||
# Temperature (Optional)
|
||||
# Controls randomness in AI responses. Lower = more deterministic.
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models).
|
||||
# Claude 4.7 and later reject it; the request is then retried without it.
|
||||
# TEMPERATURE=0
|
||||
|
||||
# Debug Logging (Optional)
|
||||
# Log the structure of the messages each chat request sends to the model
|
||||
# DEBUG_LLM_PAYLOAD=true
|
||||
|
||||
# Access Control (Optional)
|
||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||
|
||||
|
||||
@@ -6,10 +6,15 @@ import type {
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
// display_diagram replaces the document with this one page
|
||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
@@ -47,6 +52,8 @@ type ValidateDiagramFn = (
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
// Tool calls the streaming preview must leave alone (shared with it)
|
||||
processedToolCallsRef: MutableRefObject<Set<string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: MutableRefObject<number>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
@@ -57,6 +64,11 @@ interface UseDiagramToolHandlersParams {
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
sessionId?: string
|
||||
// Called when a screenshot check begins; the function it returns
|
||||
// tells whether the user pressed Stop in this turn, also after the next
|
||||
// message was sent. A check that has not started then is skipped (one
|
||||
// already running is cancelled by the caller).
|
||||
watchStop?: () => () => boolean
|
||||
onValidationStateChange?: (
|
||||
toolCallId: string,
|
||||
state: ValidationState,
|
||||
@@ -73,6 +85,7 @@ interface UseDiagramToolHandlersParams {
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
@@ -82,6 +95,7 @@ export function useDiagramToolHandlers({
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
sessionId,
|
||||
watchStop,
|
||||
onValidationStateChange,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
// Helper to update validation state
|
||||
@@ -113,18 +127,39 @@ export function useDiagramToolHandlers({
|
||||
)
|
||||
}
|
||||
|
||||
processedToolCallsRef.current.add(toolCall.toolCallId)
|
||||
// Only display_diagram, edit_diagram and a completing append_diagram
|
||||
// put their result on the canvas. Other tools (get_shape_library,
|
||||
// which the server runs, still arrives here) leave the stored
|
||||
// originals for the preview code to undo.
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals())
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Stored originals belong to previews not handled yet: this call's, and
|
||||
// those of earlier calls with invalid input, which never get to the
|
||||
// handler. The first is the diagram before all of them. A call that
|
||||
// draws its result replaces those previews, so the preview code must
|
||||
// neither draw them again nor undo them later. Returns that first one.
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
processedToolCallsRef.current.add(id)
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
return originalXml
|
||||
}
|
||||
|
||||
// originalXml: the diagram before the streamed previews, if any were drawn
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
originalXml: string | undefined,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
@@ -173,14 +208,18 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||
// create_new_diagram, then load it
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Undo the streamed preview, as a failed edit does: the canvas
|
||||
// keeps the diagram from before this failed call
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
@@ -212,9 +251,12 @@ ${finalXml}
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
validateDiagram &&
|
||||
// At most this many checks per user turn, passed or not
|
||||
validationRetryCountRef.current < MAX_VALIDATION_RETRIES
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
const stopped = watchStop?.()
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
@@ -224,15 +266,21 @@ ${finalXml}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
// Stopped while the screenshot was taken: no check. The
|
||||
// chat waits for this handler, so it must end now.
|
||||
if (stopped?.()) {
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
} else if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
// Each retry is a new tool call, so count attempts per user turn
|
||||
// Each retry is a new tool call, so count attempts
|
||||
// per user turn (the chat resets it when the user sends)
|
||||
const attempt = validationRetryCountRef.current + 1
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
// Notify UI that we're validating (include the image)
|
||||
updateValidationState(
|
||||
@@ -252,8 +300,6 @@ ${finalXml}
|
||||
|
||||
if (!result.valid) {
|
||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
@@ -289,8 +335,6 @@ ${finalXml}
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current = 0
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
@@ -306,8 +350,6 @@ ${finalXml}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - reset retry count
|
||||
validationRetryCountRef.current = 0
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
@@ -330,6 +372,16 @@ ${finalXml}
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// Cancelled by Stop: the diagram stays, unchecked
|
||||
if ((error as Error)?.name === "AbortError") {
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
return
|
||||
}
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
@@ -363,20 +415,18 @@ ${finalXml}
|
||||
}
|
||||
}
|
||||
|
||||
// originalXml: the diagram before the streamed previews, if any were drawn.
|
||||
// Operations apply to it, the same base XML that streaming used.
|
||||
const handleEditDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
originalXml: string | undefined,
|
||||
) => {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: DiagramOperation[]
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
// On failure, undo the streaming preview so the canvas matches the XML
|
||||
// reported back to the model
|
||||
const restoreOriginal = () => {
|
||||
@@ -396,27 +446,19 @@ ${finalXml}
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||
const { result: editedXml, errors } = applyDiagramOperations(
|
||||
currentXml,
|
||||
operations,
|
||||
)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
// All or nothing, checked like the MCP server's edit_diagram.
|
||||
// The model sees the first page, so edits target it.
|
||||
const outcome = editDiagram(currentXml, operations, {})
|
||||
if (!outcome.ok) {
|
||||
const reason = outcome.pageError
|
||||
? outcome.errors[0]
|
||||
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
errorText: `${reason}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
@@ -425,44 +467,16 @@ ${currentXml}
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
onDisplayChart(outcome.xml, true)
|
||||
onExport()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
@@ -483,8 +497,6 @@ ${currentXml || "No XML available"}
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,10 +557,18 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
// It draws now: it takes the stored originals, as display_diagram
|
||||
const originalXml = prepared.ok ? takeOriginals() : undefined
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
// Loading failed: back to the diagram before the previews
|
||||
if (prepared.ok && originalXml) {
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
flattenModels,
|
||||
type ModelConfig,
|
||||
type MultiModelConfig,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
@@ -63,6 +64,28 @@ function migrateOldConfig(): MultiModelConfig | null {
|
||||
return config
|
||||
}
|
||||
|
||||
const isKnownProvider = (p: { provider: string }) =>
|
||||
Object.hasOwn(PROVIDER_INFO, p.provider)
|
||||
|
||||
/**
|
||||
* The stored config without providers this version does not know (saved
|
||||
* by another version, or edited by hand): they would break every list of
|
||||
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
||||
*/
|
||||
function parseStoredConfig(stored: string): MultiModelConfig {
|
||||
const config = JSON.parse(stored) as MultiModelConfig
|
||||
const known = config.providers.filter(isKnownProvider)
|
||||
if (known.length < config.providers.length) {
|
||||
console.warn(
|
||||
"Skipped saved providers this version does not know:",
|
||||
config.providers
|
||||
.filter((p) => !isKnownProvider(p))
|
||||
.map((p) => p.provider),
|
||||
)
|
||||
}
|
||||
return { ...config, providers: known }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from localStorage
|
||||
*/
|
||||
@@ -73,7 +96,7 @@ function loadConfig(): MultiModelConfig {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as MultiModelConfig
|
||||
return parseStoredConfig(stored)
|
||||
} catch {
|
||||
console.error("Failed to parse model config")
|
||||
}
|
||||
@@ -98,7 +121,26 @@ function loadConfig(): MultiModelConfig {
|
||||
*/
|
||||
function saveConfig(config: MultiModelConfig): void {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
// Providers this version does not know are not in config: keep them,
|
||||
// with their keys, for the version that saved them
|
||||
let unknown: MultiModelConfig["providers"] = []
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
||||
(p) => !isKnownProvider(p),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Unreadable: nothing to keep
|
||||
}
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.modelConfigs,
|
||||
JSON.stringify({
|
||||
...config,
|
||||
providers: [...config.providers, ...unknown],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,10 +205,15 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [])
|
||||
|
||||
// Load server models on mount (if any)
|
||||
// Load server models on mount (if any), and again when the desktop app
|
||||
// restarted its server for another preset
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
loadServerModels()
|
||||
return window.electronAPI?.onServerRestarted?.(loadServerModels)
|
||||
}, [])
|
||||
|
||||
function loadServerModels() {
|
||||
fetch(getApiEndpoint("/api/server-models"))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
@@ -192,7 +239,19 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
id?.startsWith("server:") &&
|
||||
!raw.some((m) => m.id === id)
|
||||
if (id && !isStale) return prev
|
||||
const fallback = defaultServerModelId(raw)
|
||||
// Saved before non-ASCII characters in provider names
|
||||
// got into the id: they were dropped from it
|
||||
const renamed = raw.filter(
|
||||
(m) =>
|
||||
`server:${m.providerLabel
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
|
||||
)
|
||||
const fallback =
|
||||
renamed.length === 1
|
||||
? renamed[0].id
|
||||
: defaultServerModelId(raw)
|
||||
return fallback === id
|
||||
? prev
|
||||
: { ...prev, selectedModelId: fallback }
|
||||
@@ -202,7 +261,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
console.error("Error while loading server models:", error)
|
||||
setServerLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Save config whenever it changes (after initial load)
|
||||
useEffect(() => {
|
||||
@@ -456,7 +515,8 @@ export function getSelectedAIConfig(): {
|
||||
|
||||
let config: MultiModelConfig
|
||||
try {
|
||||
config = JSON.parse(stored)
|
||||
// Unknown providers would break the model lookup below
|
||||
config = parseStoredConfig(stored)
|
||||
} catch {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
+185
-106
@@ -13,10 +13,12 @@ import {
|
||||
getSession,
|
||||
isIndexedDBAvailable,
|
||||
migrateFromLocalStorage,
|
||||
readSessionCount,
|
||||
type SessionMetadata,
|
||||
type StoredMessage,
|
||||
saveSession,
|
||||
} from "@/lib/session-storage"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
export interface SessionData {
|
||||
messages: StoredMessage[]
|
||||
@@ -26,6 +28,13 @@ export interface SessionData {
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
}
|
||||
|
||||
// Taken right before a save's data is read: the chat on screen then, and
|
||||
// the order of the reads
|
||||
export interface SaveTicket {
|
||||
generation: number
|
||||
seq: number
|
||||
}
|
||||
|
||||
export interface UseSessionManagerReturn {
|
||||
// State
|
||||
sessions: SessionMetadata[]
|
||||
@@ -37,13 +46,18 @@ export interface UseSessionManagerReturn {
|
||||
// Actions
|
||||
switchSession: (id: string) => Promise<SessionData | null>
|
||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||
// ticket: getSaveTicket() before the data was read (by default, now).
|
||||
// The save is dropped if another chat is on screen when its turn comes,
|
||||
// or if a copy of this chat read later was saved already.
|
||||
// Resolves to false when the save failed (the user was told)
|
||||
saveCurrentSession: (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
) => Promise<void>
|
||||
ticket?: SaveTicket,
|
||||
) => Promise<boolean>
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
getChatGeneration: () => number
|
||||
getSaveTicket: () => SaveTicket
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
@@ -78,6 +92,23 @@ export function useSessionManager(
|
||||
const isInitializedRef = useRef(false)
|
||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||
const urlChangeSequenceRef = useRef(0)
|
||||
// The chat on screen, read by saves that run after a render or a wait
|
||||
const currentSessionRef = useRef<ChatSession | null>(null)
|
||||
// Goes up each time another chat is put on screen (creating the
|
||||
// session of the chat on screen does not count)
|
||||
const chatGenerationRef = useRef(0)
|
||||
// Saves run one at a time, so two saves of a new chat create it once
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
// The last ticket number, and that of the newest data saved
|
||||
const saveSeqRef = useRef(0)
|
||||
const savedSeqRef = useRef(0)
|
||||
|
||||
const changeChat = useCallback((session: ChatSession | null) => {
|
||||
chatGenerationRef.current++
|
||||
currentSessionRef.current = session
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session?.id ?? null)
|
||||
}, [])
|
||||
|
||||
// Load sessions list
|
||||
const refreshSessions = useCallback(async () => {
|
||||
@@ -113,14 +144,36 @@ export function useSessionManager(
|
||||
// Load sessions list
|
||||
const metadata = await getAllSessionMetadata()
|
||||
setSessions(metadata)
|
||||
// The desktop app may try its other port next launch, where
|
||||
// an older version may have saved the chats: only when this
|
||||
// origin surely has none (a failed read is not "none") and
|
||||
// keeps no model settings or keys either
|
||||
if (window.electronAPI?.chatsLoaded) {
|
||||
const count = await readSessionCount()
|
||||
// The app saves an empty config on its first load; the
|
||||
// providers are what holds the keys, besides an access
|
||||
// code
|
||||
let hasSettings = true
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
||||
"{}",
|
||||
)
|
||||
hasSettings =
|
||||
(config.providers?.length ?? 0) > 0 ||
|
||||
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
||||
} catch {
|
||||
// Unreadable: treat as settings, and stay
|
||||
}
|
||||
if (count !== null && !hasSettings) {
|
||||
window.electronAPI.chatsLoaded(count).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Only load a session if initialSessionId is provided (from URL param)
|
||||
if (initialSessionId) {
|
||||
const session = await getSession(initialSessionId)
|
||||
if (session) {
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
}
|
||||
if (session) changeChat(session)
|
||||
// If session not found, stay in blank state (URL has invalid session ID)
|
||||
}
|
||||
// If no initialSessionId, start with blank state (no auto-restore)
|
||||
@@ -132,7 +185,7 @@ export function useSessionManager(
|
||||
}
|
||||
|
||||
init()
|
||||
}, [initialSessionId])
|
||||
}, [initialSessionId, changeChat])
|
||||
|
||||
// Handle URL session ID changes after initialization
|
||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||
@@ -147,6 +200,7 @@ export function useSessionManager(
|
||||
|
||||
async function handleSessionIdChange() {
|
||||
if (initialSessionId) {
|
||||
const generation = chatGenerationRef.current
|
||||
// URL has session ID - load it
|
||||
const session = await getSession(initialSessionId)
|
||||
|
||||
@@ -155,16 +209,13 @@ export function useSessionManager(
|
||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||
return
|
||||
}
|
||||
// Another chat was put on screen meanwhile (New Chat right
|
||||
// after this one got its session id in the URL): keep it
|
||||
if (generation !== chatGenerationRef.current) return
|
||||
|
||||
if (session) {
|
||||
// Only update if the session is different from current
|
||||
setCurrentSessionId((current) => {
|
||||
if (current !== session.id) {
|
||||
setCurrentSession(session)
|
||||
return session.id
|
||||
}
|
||||
return current
|
||||
})
|
||||
// Only update if the session is different from current
|
||||
if (session && currentSessionRef.current?.id !== session.id) {
|
||||
changeChat(session)
|
||||
}
|
||||
}
|
||||
// Removed: else clause that clears session
|
||||
@@ -173,7 +224,7 @@ export function useSessionManager(
|
||||
}
|
||||
|
||||
handleSessionIdChange()
|
||||
}, [initialSessionId, isAvailable])
|
||||
}, [initialSessionId, isAvailable, changeChat])
|
||||
|
||||
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
||||
const lastFocusRefreshRef = useRef(0)
|
||||
@@ -195,9 +246,11 @@ export function useSessionManager(
|
||||
async (id: string): Promise<SessionData | null> => {
|
||||
if (id === currentSessionId) return null
|
||||
|
||||
// Save current session first if it has messages
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
await saveSession(currentSession)
|
||||
// Save current session first if it has messages (as saved
|
||||
// last: the caller may have just saved it)
|
||||
const current = currentSessionRef.current
|
||||
if (current && current.messages.length > 0) {
|
||||
await saveSession(current)
|
||||
}
|
||||
|
||||
// Load the target session
|
||||
@@ -207,9 +260,7 @@ export function useSessionManager(
|
||||
return null
|
||||
}
|
||||
|
||||
// Update state
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
changeChat(session)
|
||||
|
||||
return {
|
||||
messages: session.messages,
|
||||
@@ -219,7 +270,7 @@ export function useSessionManager(
|
||||
diagramHistory: session.diagramHistory,
|
||||
}
|
||||
},
|
||||
[currentSessionId, currentSession],
|
||||
[currentSessionId, changeChat],
|
||||
)
|
||||
|
||||
// Delete a session
|
||||
@@ -229,111 +280,137 @@ export function useSessionManager(
|
||||
await deleteSessionFromDB(id)
|
||||
|
||||
// If deleting current session, clear state (caller will show new empty session)
|
||||
if (wasCurrentSession) {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}
|
||||
if (wasCurrentSession) changeChat(null)
|
||||
|
||||
await refreshSessions()
|
||||
|
||||
return { wasCurrentSession }
|
||||
},
|
||||
[currentSessionId, refreshSessions],
|
||||
[currentSessionId, refreshSessions, changeChat],
|
||||
)
|
||||
|
||||
// Save current session data (debounced externally by caller)
|
||||
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||
const saveCurrentSession = useCallback(
|
||||
async (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
): Promise<void> => {
|
||||
// If forSessionId is provided, verify it matches current session
|
||||
// This prevents stale debounced saves from overwriting a newly switched session
|
||||
if (
|
||||
forSessionId !== undefined &&
|
||||
forSessionId !== currentSessionId
|
||||
) {
|
||||
return
|
||||
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
||||
// The data is of the chat on screen when it was read
|
||||
const { generation, seq } = ticket ?? {
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}
|
||||
// Nothing can be stored without IndexedDB
|
||||
if (!isIndexedDBAvailable()) return
|
||||
const run = async (): Promise<boolean> => {
|
||||
// That chat is no longer on screen (leaving it saved it)
|
||||
if (generation !== chatGenerationRef.current) return true
|
||||
// A copy read later was saved already (one that waited for
|
||||
// its thumbnail must not undo it)
|
||||
if (seq < savedSeqRef.current) return true
|
||||
// Nothing can be stored without IndexedDB
|
||||
if (!isIndexedDBAvailable()) return true
|
||||
// The user may put another chat on screen while this one is
|
||||
// written; the stored copy is still right, the state is not
|
||||
const stillOnScreen = () =>
|
||||
chatGenerationRef.current === generation
|
||||
const currentSession = currentSessionRef.current
|
||||
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
const newSession: ChatSession = {
|
||||
...createEmptySession(),
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
const newSession: ChatSession = {
|
||||
...createEmptySession(),
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
}
|
||||
// Without a stored session, keep no session id (it would end
|
||||
// up in the URL and point to nothing after a reload)
|
||||
if (!(await saveSession(newSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
await enforceSessionLimit()
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = newSession
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
}
|
||||
await refreshSessions()
|
||||
return true
|
||||
}
|
||||
|
||||
// Update existing session
|
||||
const updatedSession: ChatSession = {
|
||||
...currentSession,
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ??
|
||||
currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
currentSession.title === "New Chat" &&
|
||||
data.messages.length > 0
|
||||
? extractTitle(data.messages)
|
||||
: currentSession.title,
|
||||
}
|
||||
// Without a stored session, keep no session id (it would end
|
||||
// up in the URL and point to nothing after a reload)
|
||||
if (!(await saveSession(newSession))) {
|
||||
|
||||
if (!(await saveSession(updatedSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = updatedSession
|
||||
setCurrentSession(updatedSession)
|
||||
}
|
||||
await enforceSessionLimit()
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
await refreshSessions()
|
||||
return
|
||||
}
|
||||
|
||||
// Update existing session
|
||||
const updatedSession: ChatSession = {
|
||||
...currentSession,
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
currentSession.title === "New Chat" &&
|
||||
data.messages.length > 0
|
||||
? extractTitle(data.messages)
|
||||
: currentSession.title,
|
||||
// Update sessions list metadata
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === updatedSession.id
|
||||
? {
|
||||
...s,
|
||||
title: updatedSession.title,
|
||||
updatedAt: updatedSession.updatedAt,
|
||||
messageCount: updatedSession.messages.length,
|
||||
hasDiagram:
|
||||
!!updatedSession.diagramXml &&
|
||||
updatedSession.diagramXml.trim().length >
|
||||
0,
|
||||
thumbnailDataUrl:
|
||||
updatedSession.thumbnailDataUrl,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!(await saveSession(updatedSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
setCurrentSession(updatedSession)
|
||||
|
||||
// Update sessions list metadata
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === updatedSession.id
|
||||
? {
|
||||
...s,
|
||||
title: updatedSession.title,
|
||||
updatedAt: updatedSession.updatedAt,
|
||||
messageCount: updatedSession.messages.length,
|
||||
hasDiagram:
|
||||
!!updatedSession.diagramXml &&
|
||||
updatedSession.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
)
|
||||
const result = saveQueueRef.current.then(run)
|
||||
saveQueueRef.current = result.catch(() => {})
|
||||
return result
|
||||
},
|
||||
[currentSession, currentSessionId, refreshSessions, dict],
|
||||
[refreshSessions, dict],
|
||||
)
|
||||
|
||||
// Clear current session state (for starting fresh without loading another session)
|
||||
const clearCurrentSession = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}, [])
|
||||
changeChat(null)
|
||||
}, [changeChat])
|
||||
|
||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||
|
||||
const getSaveTicket = useCallback(
|
||||
(): SaveTicket => ({
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -346,5 +423,7 @@ export function useSessionManager(
|
||||
saveCurrentSession,
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,22 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||
[submit],
|
||||
)
|
||||
|
||||
/**
|
||||
* End a running check (the user pressed Stop): its promise rejects with
|
||||
* an AbortError, so the tool handler can finish at once.
|
||||
*/
|
||||
const cancel = useCallback(() => {
|
||||
const pending = pendingValidationRef.current
|
||||
if (!pending) return
|
||||
pendingValidationRef.current = null
|
||||
stop()
|
||||
pending.reject(new DOMException("Validation cancelled", "AbortError"))
|
||||
}, [stop])
|
||||
|
||||
/**
|
||||
* Validate with fallback - returns default valid result on error.
|
||||
* Use this to avoid blocking the user on validation failures.
|
||||
* A cancelled check is passed on as its AbortError.
|
||||
*/
|
||||
const validateWithFallback = useCallback(
|
||||
async (
|
||||
@@ -115,6 +128,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||
try {
|
||||
return await validate(imageData, sessionId)
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === "AbortError") throw error
|
||||
console.warn(
|
||||
"[useValidateDiagram] Validation failed, using fallback:",
|
||||
error,
|
||||
@@ -130,6 +144,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||
validate,
|
||||
validateWithFallback,
|
||||
stop,
|
||||
cancel,
|
||||
|
||||
// State
|
||||
isValidating: isLoading,
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
/**
|
||||
* Refuse a POST that a page on another website could have sent. A browser
|
||||
* sends a cross-site POST without asking first (CORS preflight) only with a
|
||||
* text or form body, so the routes take JSON only. In the desktop app also
|
||||
* refuse a foreign Host: a site that points its own domain name at
|
||||
* 127.0.0.1 (DNS rebinding) is same-origin with the local server, but its
|
||||
* requests carry that domain. A request the server builds itself has no
|
||||
* Host. Returns the response to send, or null when the request may go on.
|
||||
*/
|
||||
export function rejectCrossSite(req: Request): Response | null {
|
||||
const contentType = req.headers.get("content-type") ?? ""
|
||||
if (!/^\s*application\/json\b/i.test(contentType)) {
|
||||
return Response.json(
|
||||
{ error: "Content-Type must be application/json" },
|
||||
{ status: 415 },
|
||||
)
|
||||
}
|
||||
const host = req.headers.get("host")
|
||||
if (
|
||||
process.env.NEXT_AI_DRAWIO_DESKTOP === "1" &&
|
||||
host &&
|
||||
!/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host)
|
||||
) {
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the x-access-code header against ACCESS_CODE_LIST.
|
||||
* Returns a 401 response to send back when the check fails, or null when the
|
||||
|
||||
+438
-705
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
const NON_CHAT_MODEL_TYPES = new Set([
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"rerank",
|
||||
"transcription",
|
||||
"tts",
|
||||
"video",
|
||||
])
|
||||
|
||||
type AihubmixModelListPayload = {
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type AihubmixModelRecord = {
|
||||
model_id?: unknown
|
||||
types?: unknown
|
||||
}
|
||||
|
||||
function getModelTypes(types: unknown): Set<string> {
|
||||
if (typeof types !== "string") {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
return new Set(
|
||||
types
|
||||
.split(",")
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
|
||||
function isChatModel(record: AihubmixModelRecord): record is {
|
||||
model_id: string
|
||||
types: string
|
||||
} {
|
||||
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const types = getModelTypes(record.types)
|
||||
if (!types.has("llm")) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
|
||||
}
|
||||
|
||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
||||
const data = (payload as AihubmixModelListPayload)?.data
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const modelIds: string[] = []
|
||||
|
||||
for (const item of data) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = item as AihubmixModelRecord
|
||||
if (!isChatModel(record)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const modelId = record.model_id.trim()
|
||||
if (seen.has(modelId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(modelId)
|
||||
modelIds.push(modelId)
|
||||
}
|
||||
|
||||
return modelIds
|
||||
}
|
||||
@@ -53,13 +53,6 @@ export function validateFileParts(messages: any[]): {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Helper function to check if diagram is minimal/empty
|
||||
// Empty means no mxCell besides the root cells "0" and "1". Cells drawn in
|
||||
// draw.io get random ids, so checking for id="2" is not enough.
|
||||
export function isMinimalDiagram(xml: string): boolean {
|
||||
return !/<mxCell\b[^>]*\bid="(?![01]")/.test(xml)
|
||||
}
|
||||
|
||||
// A tool-call input providers accept: a non-empty JSON object
|
||||
function isValidToolInput(input: unknown): boolean {
|
||||
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { wrapLanguageModel } from "ai"
|
||||
import { rejectionText } from "@/lib/output-token-limit"
|
||||
|
||||
type WrappedModel = ReturnType<typeof wrapLanguageModel>
|
||||
|
||||
/**
|
||||
* Claude 4.7 and later answer a non-default temperature, top_p or top_k,
|
||||
* and the extended thinking budget (thinking type "enabled"), with a 400.
|
||||
* TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these.
|
||||
*/
|
||||
const DEPRECATED_PARAM =
|
||||
/`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i
|
||||
|
||||
interface CallParams {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
providerOptions?: Record<string, Record<string, unknown> | undefined>
|
||||
}
|
||||
|
||||
// What these models take instead of a budget. Without display "summarized"
|
||||
// they think but send no thinking text to show.
|
||||
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
|
||||
|
||||
/** Turn a thinking config of type "enabled" stored under key into adaptive */
|
||||
function adaptiveThinking(
|
||||
options: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const config = options?.[key] as { type?: string } | undefined
|
||||
if (config?.type !== "enabled") return options
|
||||
return { ...options, [key]: ADAPTIVE_THINKING }
|
||||
}
|
||||
|
||||
/**
|
||||
* The params without the settings newer Claude models reject, or null when
|
||||
* the error is about something else or there is nothing to change. The
|
||||
* model then runs with its default sampling, and a thinking budget becomes
|
||||
* adaptive thinking.
|
||||
*/
|
||||
export function withoutDeprecatedParams<T extends CallParams>(
|
||||
error: unknown,
|
||||
params: T,
|
||||
): T | null {
|
||||
const text = rejectionText(error)
|
||||
if (!text || !DEPRECATED_PARAM.test(text)) return null
|
||||
|
||||
const { temperature, topP, topK, ...rest } = params
|
||||
const options = params.providerOptions
|
||||
const anthropic = adaptiveThinking(options?.anthropic, "thinking")
|
||||
const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig")
|
||||
const changed =
|
||||
temperature !== undefined ||
|
||||
topP !== undefined ||
|
||||
topK !== undefined ||
|
||||
anthropic !== options?.anthropic ||
|
||||
bedrock !== options?.bedrock
|
||||
if (!changed) return null
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(options && {
|
||||
providerOptions: {
|
||||
...options,
|
||||
...(anthropic && { anthropic }),
|
||||
...(bedrock && { bedrock }),
|
||||
},
|
||||
}),
|
||||
} as T
|
||||
}
|
||||
|
||||
/** Retry the stream once without the settings newer Claude models reject. */
|
||||
export function withDeprecatedParamsFallback(
|
||||
model: WrappedModel,
|
||||
): WrappedModel {
|
||||
return wrapLanguageModel({
|
||||
model,
|
||||
middleware: {
|
||||
specificationVersion: "v3",
|
||||
async wrapStream({ doStream, params, model: inner }) {
|
||||
try {
|
||||
return await doStream()
|
||||
} catch (error) {
|
||||
const retry = withoutDeprecatedParams(error, params)
|
||||
if (!retry) throw error
|
||||
console.warn(
|
||||
"[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking",
|
||||
)
|
||||
return await inner.doStream(retry)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -64,10 +64,13 @@ interface QuotaCheckResult {
|
||||
* Check all quotas and increment request count atomically.
|
||||
* Uses composite key (PK=user, SK=date) for per-day tracking.
|
||||
* Each day automatically gets a new item - no explicit reset needed.
|
||||
* A request limit of 0 means none; increment 0 checks the limits without
|
||||
* counting a request (the screenshot check).
|
||||
*/
|
||||
export async function checkAndIncrementRequest(
|
||||
ip: string,
|
||||
limits: QuotaLimits,
|
||||
increment = 1,
|
||||
): Promise<QuotaCheckResult> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) {
|
||||
@@ -99,7 +102,7 @@ export async function checkAndIncrementRequest(
|
||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||
`,
|
||||
ExpressionAttributeValues: {
|
||||
":one": { N: "1" },
|
||||
":one": { N: String(increment) },
|
||||
":minute": { S: currentMinute },
|
||||
":reqLimit": { N: String(limits.requests || 999999) },
|
||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
||||
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
||||
"validationFailed": "Diagram validation failed. Please try regenerating.",
|
||||
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
|
||||
"failedToProcess": "Failed to process diagram. Please try regenerating.",
|
||||
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
|
||||
"failedToSave": "Failed to save messages to localStorage",
|
||||
"failedToRestore": "Failed to restore from localStorage",
|
||||
@@ -189,7 +186,24 @@
|
||||
"failedToLoadExample": "Error loading example image",
|
||||
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
||||
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
|
||||
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again."
|
||||
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
|
||||
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
|
||||
"continueWithoutSaving": "Continue without saving",
|
||||
"llm": {
|
||||
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
|
||||
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
|
||||
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
|
||||
"insufficient_quota": "The provider account has no credit or quota left.",
|
||||
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
|
||||
"context_too_long": "The conversation is too long for this model. Start a new chat or pick a model with a larger context.",
|
||||
"images_unsupported": "This model doesn't support image input.",
|
||||
"tools_unsupported": "This model doesn't support tool calls, which drawing needs. Pick another model.",
|
||||
"output_truncated": "The output was cut off before the diagram was complete. Try a simpler request or raise the output limit in settings.",
|
||||
"provider_unavailable": "The provider is having problems. Try again later.",
|
||||
"cannot_connect": "Could not reach the provider. Check the base URL and your network.",
|
||||
"timeout": "The provider did not answer in time.",
|
||||
"openModelSettings": "Open model settings"
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "Daily Quota Reached",
|
||||
@@ -236,6 +250,7 @@
|
||||
"reasoning": {
|
||||
"thinking": "Thinking...",
|
||||
"thoughtFor": "Thought for {duration} seconds",
|
||||
"thoughtForOne": "Thought for 1 second",
|
||||
"thoughtBrief": "Thought for a few seconds"
|
||||
},
|
||||
"dev": {
|
||||
@@ -372,6 +387,11 @@
|
||||
"enterSecretKey": "Enter your secret access key",
|
||||
"baseUrl": "Base URL",
|
||||
"optional": "(optional)",
|
||||
"getApiKey": "Get API key",
|
||||
"fetchModels": "Fetch models from the provider",
|
||||
"noTools": "no tool calls",
|
||||
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
|
||||
"requestUrl": "Requests go to {url}",
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "ネットワークエラー。接続を確認してください。",
|
||||
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
||||
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
||||
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
|
||||
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
|
||||
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
|
||||
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
|
||||
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
|
||||
"failedToRestore": "localStorage からの復元に失敗しました",
|
||||
@@ -189,7 +186,24 @@
|
||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。"
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
|
||||
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
|
||||
"continueWithoutSaving": "保存せずに続ける",
|
||||
"llm": {
|
||||
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
|
||||
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
|
||||
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
|
||||
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
|
||||
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
|
||||
"context_too_long": "会話がこのモデルで扱える長さを超えています。新しいチャットを始めるか、より長いコンテキストに対応したモデルを選んでください。",
|
||||
"images_unsupported": "このモデルは画像入力に対応していません。",
|
||||
"tools_unsupported": "このモデルはツール呼び出しに対応していません。作図にはツール呼び出しが必要です。別のモデルを選んでください。",
|
||||
"output_truncated": "ダイアグラムが完成する前に出力が途中で切れました。リクエストを簡単にするか、設定で出力上限を上げてください。",
|
||||
"provider_unavailable": "プロバイダーで問題が発生しています。しばらくしてから再試行してください。",
|
||||
"cannot_connect": "プロバイダーに接続できません。Base URL とネットワークを確認してください。",
|
||||
"timeout": "プロバイダーから時間内に応答がありませんでした。",
|
||||
"openModelSettings": "モデル設定を開く"
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "1日の割当量に達しました",
|
||||
@@ -236,6 +250,7 @@
|
||||
"reasoning": {
|
||||
"thinking": "考え中...",
|
||||
"thoughtFor": "{duration} 秒考えました",
|
||||
"thoughtForOne": "1 秒考えました",
|
||||
"thoughtBrief": "数秒考えました"
|
||||
},
|
||||
"dev": {
|
||||
@@ -326,6 +341,11 @@
|
||||
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||
"baseUrl": "ベース URL",
|
||||
"optional": "(オプション)",
|
||||
"getApiKey": "API キーを取得",
|
||||
"fetchModels": "プロバイダーからモデル一覧を取得",
|
||||
"noTools": "ツール呼び出し非対応",
|
||||
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "網路錯誤。請檢查您的連線。",
|
||||
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
||||
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
||||
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
|
||||
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
|
||||
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
|
||||
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
||||
"failedToSave": "無法儲存訊息到 localStorage",
|
||||
"failedToRestore": "無法從 localStorage 還原",
|
||||
@@ -189,7 +186,24 @@
|
||||
"failedToLoadExample": "載入範例圖片時出錯",
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。"
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
|
||||
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
|
||||
"continueWithoutSaving": "不儲存,繼續",
|
||||
"llm": {
|
||||
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
|
||||
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
|
||||
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
|
||||
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
|
||||
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
|
||||
"context_too_long": "對話內容超過了這個模型能處理的長度。請開啟新的對話,或換一個上下文更長的模型。",
|
||||
"images_unsupported": "這個模型不支援圖片輸入。",
|
||||
"tools_unsupported": "這個模型不支援工具呼叫,而繪圖需要工具呼叫。請換一個模型。",
|
||||
"output_truncated": "輸出在圖表完成之前就被截斷了。請簡化請求,或在設定中調高輸出上限。",
|
||||
"provider_unavailable": "服務商發生問題,請稍後再試。",
|
||||
"cannot_connect": "無法連線到服務商,請檢查 Base URL 和網路。",
|
||||
"timeout": "服務商沒有及時回應。",
|
||||
"openModelSettings": "開啟模型設定"
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已達每日配額",
|
||||
@@ -236,6 +250,7 @@
|
||||
"reasoning": {
|
||||
"thinking": "思考中...",
|
||||
"thoughtFor": "思考了 {duration} 秒",
|
||||
"thoughtForOne": "思考了 1 秒",
|
||||
"thoughtBrief": "思考了幾秒鐘"
|
||||
},
|
||||
"dev": {
|
||||
@@ -372,6 +387,11 @@
|
||||
"enterSecretKey": "輸入您的 Secret Key",
|
||||
"baseUrl": "基礎 URL",
|
||||
"optional": "(可選)",
|
||||
"getApiKey": "取得 API Key",
|
||||
"fetchModels": "從服務商取得模型清單",
|
||||
"noTools": "不支援工具呼叫",
|
||||
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
|
||||
"requestUrl": "請求將傳送至 {url}",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "网络错误。请检查您的连接。",
|
||||
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
|
||||
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
|
||||
"validationFailed": "图表验证失败。请尝试重新生成。",
|
||||
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
|
||||
"failedToProcess": "无法处理图表。请尝试重新生成。",
|
||||
"sessionCorrupted": "会话数据已损坏。重新开始。",
|
||||
"failedToSave": "无法保存消息到 localStorage",
|
||||
"failedToRestore": "无法从 localStorage 恢复",
|
||||
@@ -189,7 +186,24 @@
|
||||
"failedToLoadExample": "加载示例图片时出错",
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。"
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
|
||||
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
|
||||
"continueWithoutSaving": "不保存,继续",
|
||||
"llm": {
|
||||
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
|
||||
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
|
||||
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
|
||||
"insufficient_quota": "服务商账户的余额或额度已经用完。",
|
||||
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
|
||||
"context_too_long": "对话内容超过了这个模型能处理的长度。请新开一个对话,或换一个上下文更长的模型。",
|
||||
"images_unsupported": "这个模型不支持图片输入。",
|
||||
"tools_unsupported": "这个模型不支持工具调用,而画图需要工具调用。请换一个模型。",
|
||||
"output_truncated": "输出在图画完之前就被截断了。请简化请求,或在设置里调高输出上限。",
|
||||
"provider_unavailable": "服务商出了问题,请稍后再试。",
|
||||
"cannot_connect": "连接不上服务商,请检查 Base URL 和网络。",
|
||||
"timeout": "服务商没有及时响应。",
|
||||
"openModelSettings": "打开模型设置"
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已达每日配额",
|
||||
@@ -236,6 +250,7 @@
|
||||
"reasoning": {
|
||||
"thinking": "思考中...",
|
||||
"thoughtFor": "思考了 {duration} 秒",
|
||||
"thoughtForOne": "思考了 1 秒",
|
||||
"thoughtBrief": "思考了几秒钟"
|
||||
},
|
||||
"dev": {
|
||||
@@ -372,6 +387,11 @@
|
||||
"enterSecretKey": "输入您的 Secret Key",
|
||||
"baseUrl": "基础 URL",
|
||||
"optional": "(可选)",
|
||||
"getApiKey": "获取 API Key",
|
||||
"fetchModels": "从服务商获取模型列表",
|
||||
"noTools": "不支持工具调用",
|
||||
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
|
||||
"requestUrl": "请求将发往 {url}",
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
APICallError,
|
||||
InvalidToolInputError,
|
||||
LoadAPIKeyError,
|
||||
NoSuchToolError,
|
||||
RetryError,
|
||||
ToolCallRepairError,
|
||||
} from "ai"
|
||||
|
||||
/**
|
||||
* What went wrong with a model call, for a hint the user can act on. The
|
||||
* provider's own message always goes along, because a guess can be wrong.
|
||||
*/
|
||||
export type LLMErrorCode =
|
||||
| "invalid_api_key"
|
||||
| "forbidden"
|
||||
| "model_not_found"
|
||||
| "insufficient_quota"
|
||||
| "rate_limited"
|
||||
| "context_too_long"
|
||||
| "images_unsupported"
|
||||
| "tools_unsupported"
|
||||
| "output_truncated"
|
||||
| "provider_unavailable"
|
||||
| "cannot_connect"
|
||||
| "timeout"
|
||||
| "unknown"
|
||||
|
||||
export interface LLMError {
|
||||
type: "provider"
|
||||
code: LLMErrorCode
|
||||
message: string
|
||||
}
|
||||
|
||||
// Texts that name the cause more precisely than the status code: a quota
|
||||
// error can come as 403 or 429, a context or image error as a plain 400
|
||||
const SPECIFIC_TEXTS: Array<[RegExp, LLMErrorCode]> = [
|
||||
[
|
||||
// Not "too many tokens": that is Bedrock's throttling message
|
||||
/context length|context window|maximum context|prompt is too long|input is too long|too many input tokens/i,
|
||||
"context_too_long",
|
||||
],
|
||||
[
|
||||
/image content block|image_url|does not support image|image input is not supported/i,
|
||||
"images_unsupported",
|
||||
],
|
||||
[
|
||||
/does not support tools|tool use is not supported|tools? (?:are|is) not supported|function calling is not supported/i,
|
||||
"tools_unsupported",
|
||||
],
|
||||
// Bedrock, when the output limit cut the tool call's JSON short
|
||||
[/toolUse\.input is invalid/i, "output_truncated"],
|
||||
// Bedrock, for a model id without the inference profile prefix
|
||||
[/on-demand throughput isn.t supported/i, "model_not_found"],
|
||||
[
|
||||
/insufficient[_ ]quota|insufficient balance|exceeded your current quota|credit balance is too low|余额不足/i,
|
||||
"insufficient_quota",
|
||||
],
|
||||
]
|
||||
|
||||
const STATUS_CODES: Record<number, LLMErrorCode> = {
|
||||
401: "invalid_api_key",
|
||||
402: "insufficient_quota",
|
||||
// Not "invalid key": a valid key can lack access to a model or region
|
||||
403: "forbidden",
|
||||
404: "model_not_found",
|
||||
408: "timeout",
|
||||
// A retired model
|
||||
410: "model_not_found",
|
||||
413: "context_too_long",
|
||||
429: "rate_limited",
|
||||
}
|
||||
|
||||
const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
|
||||
[
|
||||
/model[_ ]not[_ ]found|model .*does not exist|unknown model|no such model/i,
|
||||
"model_not_found",
|
||||
],
|
||||
[
|
||||
/invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i,
|
||||
"invalid_api_key",
|
||||
],
|
||||
// "too many tokens": Bedrock's throttling
|
||||
[/rate limit|too many requests|too many tokens/i, "rate_limited"],
|
||||
[
|
||||
/Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i,
|
||||
"cannot_connect",
|
||||
],
|
||||
]
|
||||
|
||||
/** Secrets a provider may echo back: API keys, Bearer tokens, key=value */
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{8,}/g, "$1-[redacted]")
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
|
||||
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
||||
.replace(
|
||||
/\b(api[_-]?key|access[_-]?key|secret|token|password|signature)(["']?\s*[:=]\s*["']?)[^\s"',&}]+/gi,
|
||||
"$1$2[redacted]",
|
||||
)
|
||||
}
|
||||
|
||||
function problemDetail(body: string): string | undefined {
|
||||
try {
|
||||
const detail = JSON.parse(body)?.detail
|
||||
return typeof detail === "string" ? detail : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The error text for the chat stream: what went wrong with the provider as
|
||||
* JSON for the hint, or the text the model must read to fix a tool call.
|
||||
* On the server's keys the provider's own text stays in the server log:
|
||||
* it can name the server's account, role or internal hosts.
|
||||
*/
|
||||
export function streamErrorText(error: unknown, hideDetails = false): string {
|
||||
// The SDK passes an invalid tool call's error as a plain string. Other
|
||||
// strings come from providers (DeepSeek's SDK sends stream errors so).
|
||||
if (
|
||||
typeof error === "string" &&
|
||||
/^(Invalid input for tool|Model tried to call unavailable tool)/.test(
|
||||
error,
|
||||
)
|
||||
) {
|
||||
return error
|
||||
}
|
||||
if (isToolCallError(error)) return (error as Error).message
|
||||
const classified = classifyLLMError(error)
|
||||
if (hideDetails) {
|
||||
console.error("[chat] Provider error:", error)
|
||||
classified.message = "The provider returned an error."
|
||||
}
|
||||
return JSON.stringify(classified)
|
||||
}
|
||||
|
||||
/**
|
||||
* Model and tool errors the SDK sends back to the model as the tool result,
|
||||
* so it can fix its call. Their text has to stay as it is.
|
||||
*/
|
||||
export function isToolCallError(error: unknown): boolean {
|
||||
return (
|
||||
InvalidToolInputError.isInstance(error) ||
|
||||
NoSuchToolError.isInstance(error) ||
|
||||
ToolCallRepairError.isInstance(error)
|
||||
)
|
||||
}
|
||||
|
||||
export function classifyLLMError(error: unknown): LLMError {
|
||||
// After the SDK's retries, the last attempt says what happened
|
||||
const e = RetryError.isInstance(error) ? error.lastError : error
|
||||
// Errors sent inside the stream can be plain objects like OpenRouter's
|
||||
// { code: 503, message }
|
||||
const plain = e as {
|
||||
message?: unknown
|
||||
code?: unknown
|
||||
statusCode?: number
|
||||
}
|
||||
const raw =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof plain?.message === "string"
|
||||
? plain.message
|
||||
: String(e)
|
||||
const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : ""
|
||||
// A problem+json body names the reason the SDK left out (NVIDIA: "Gone")
|
||||
const detail = problemDetail(body)
|
||||
const message = redact(detail ? `${raw}: ${detail}` : raw).slice(0, 500)
|
||||
const text = `${raw} ${body}`
|
||||
const status = APICallError.isInstance(e)
|
||||
? e.statusCode
|
||||
: (plain?.statusCode ??
|
||||
(typeof plain?.code === "number" ? plain.code : undefined))
|
||||
|
||||
const find = (rules: Array<[RegExp, LLMErrorCode]>) =>
|
||||
rules.find(([pattern]) => pattern.test(text))?.[1]
|
||||
const code =
|
||||
(e instanceof Error && e.name === "TimeoutError" && "timeout") ||
|
||||
(LoadAPIKeyError.isInstance(e) && "invalid_api_key") ||
|
||||
find(SPECIFIC_TEXTS) ||
|
||||
(status && STATUS_CODES[status]) ||
|
||||
(status && status >= 500 && "provider_unavailable") ||
|
||||
find(GENERAL_TEXTS) ||
|
||||
"unknown"
|
||||
return { type: "provider", code, message }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import catalog from "@/lib/model-catalog.json"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
/**
|
||||
* What models.dev knows about a model (scripts/update-model-catalog.mjs).
|
||||
* Only used for hints: requests are sent the same way either way, since
|
||||
* the data can be wrong or out of date.
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
tools: boolean
|
||||
images: boolean
|
||||
reasoning: boolean
|
||||
context?: number
|
||||
output?: number
|
||||
}
|
||||
|
||||
const CATALOG = catalog as Record<string, Record<string, ModelInfo>>
|
||||
|
||||
/**
|
||||
* The entry for a model: an exact match ignoring case, else the longest id
|
||||
* the model id starts with, followed by "-", ":" or ".". So
|
||||
* claude-sonnet-4-5-20250929 finds claude-sonnet-4-5, but gpt-4 does not
|
||||
* find gpt-4o.
|
||||
*/
|
||||
export function getModelInfo(
|
||||
provider: ProviderName,
|
||||
modelId: string,
|
||||
): ModelInfo | undefined {
|
||||
const models = CATALOG[provider]
|
||||
if (!models) return undefined
|
||||
const wanted = modelId.trim().toLowerCase()
|
||||
let best: string | undefined
|
||||
for (const id of Object.keys(models)) {
|
||||
const lower = id.toLowerCase()
|
||||
if (lower === wanted) return models[id]
|
||||
if (
|
||||
wanted.startsWith(lower) &&
|
||||
"-:.".includes(wanted[lower.length]) &&
|
||||
lower.length > (best?.length ?? 0)
|
||||
) {
|
||||
best = id
|
||||
}
|
||||
}
|
||||
return best ? models[best] : undefined
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function usableLimit(value: number): number | null {
|
||||
}
|
||||
|
||||
/** Message and body of an error that may be about the budget, or null. */
|
||||
function rejectionText(error: unknown): string | null {
|
||||
export function rejectionText(error: unknown): string | null {
|
||||
const err = error as {
|
||||
message?: unknown
|
||||
responseBody?: unknown
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createGateway } from "ai"
|
||||
import { getModelInfo } from "@/lib/model-catalog"
|
||||
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||
import {
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
/** A model a provider offers. tools is false when it cannot call tools. */
|
||||
export interface ListedModel {
|
||||
id: string
|
||||
tools?: boolean
|
||||
}
|
||||
|
||||
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
|
||||
|
||||
export function canListModels(provider: ProviderName): boolean {
|
||||
return (
|
||||
Object.hasOwn(PROVIDER_INFO, provider) &&
|
||||
!!PROVIDER_INFO[provider].modelList
|
||||
)
|
||||
}
|
||||
|
||||
// Models in OpenAI-style lists that are not for chat
|
||||
const NON_CHAT =
|
||||
/(?:^|[-/_])(?:embed(?:ding)?s?|whisper|tts|transcribe|dall-e|moderation|rerank|realtime|sora)(?:$|[-/_])|gpt-image/i
|
||||
|
||||
const NON_CHAT_AIHUBMIX_TYPES = new Set([
|
||||
"embedding",
|
||||
"image_generation",
|
||||
"rerank",
|
||||
"transcription",
|
||||
"tts",
|
||||
"video",
|
||||
])
|
||||
|
||||
/** Chat model ids from AIHubMix's public model list */
|
||||
export function extractAihubmixModelIds(payload: unknown): string[] {
|
||||
const data = (payload as { data?: unknown })?.data
|
||||
if (!Array.isArray(data)) return []
|
||||
const ids = new Set<string>()
|
||||
for (const item of data) {
|
||||
const record = item as { model_id?: unknown; types?: unknown }
|
||||
if (typeof record?.model_id !== "string" || !record.model_id.trim()) {
|
||||
continue
|
||||
}
|
||||
const types = new Set(
|
||||
typeof record.types === "string"
|
||||
? record.types.split(",").map((t) => t.trim())
|
||||
: [],
|
||||
)
|
||||
if (!types.has("llm")) continue
|
||||
if ([...NON_CHAT_AIHUBMIX_TYPES].some((t) => types.has(t))) continue
|
||||
ids.add(record.model_id.trim())
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
/**
|
||||
* An error this module wrote itself. Only these texts reach the caller:
|
||||
* the base URL is the caller's and may be an internal address, so anything
|
||||
* else (a parse error quoting the body, a network error naming a host)
|
||||
* stays in the server log.
|
||||
*/
|
||||
export class ModelListError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode?: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ModelListError"
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_LIST_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/** A fetch that reads at most MAX_LIST_BYTES of each response */
|
||||
function sizeLimitedFetch(fetchFn: typeof fetch): typeof fetch {
|
||||
return async (input, init) => {
|
||||
// Ends a download that is too large (the Gateway SDK passes no
|
||||
// signal of its own)
|
||||
const download = new AbortController()
|
||||
const signal = init?.signal
|
||||
? AbortSignal.any([init.signal, download.signal])
|
||||
: download.signal
|
||||
const response = await fetchFn(input, { ...init, signal })
|
||||
const body = await readLimitedBody(response, MAX_LIST_BYTES)
|
||||
if (body === null) {
|
||||
download.abort()
|
||||
throw new ModelListError("The model list is too large.")
|
||||
}
|
||||
// The body is already decoded and has its own length now
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
// Some statuses must have no body at all
|
||||
const noBody = [101, 204, 205, 304].includes(response.status)
|
||||
return new Response(noBody ? null : body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** GET a JSON list; a failed request carries its status for the error hint */
|
||||
async function getJson(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
fetchFn: typeof fetch,
|
||||
): Promise<any> {
|
||||
const response = await fetchFn(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ModelListError(
|
||||
`The model list request failed (${response.status})`,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
const text = await response.text()
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new ModelListError("The model list was not valid JSON.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to list from without the user's base URL: where chat goes then. For
|
||||
* Ollama without a key that is the server's Ollama, else the SDK's local
|
||||
* default; a local default in PROVIDER_INFO (SGLang's) only fills the
|
||||
* settings form.
|
||||
*/
|
||||
function listFallbackUrl(provider: ProviderName, apiKey?: string): string {
|
||||
if (provider === "ollama" && !apiKey) {
|
||||
return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api"
|
||||
}
|
||||
const url = PROVIDER_INFO[provider].defaultBaseUrl
|
||||
return url?.startsWith("https://") ? url : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's chat models, with tool support from the provider's own
|
||||
* data or else models.dev. Only the client's key is used, so the server's
|
||||
* keys never go to a URL the client chose.
|
||||
*/
|
||||
export async function listProviderModels(
|
||||
provider: ProviderName,
|
||||
{ apiKey, baseUrl }: { apiKey?: string; baseUrl?: string },
|
||||
unlimitedFetch: typeof fetch = fetch,
|
||||
): Promise<ListedModel[]> {
|
||||
const fetchFn = sizeLimitedFetch(unlimitedFetch)
|
||||
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider, apiKey))
|
||||
const bearer: Record<string, string> = apiKey
|
||||
? { Authorization: `Bearer ${apiKey}` }
|
||||
: {}
|
||||
let models: ListedModel[]
|
||||
|
||||
// AIHubMix has a public list, unless the user points to another
|
||||
// endpoint, which is OpenAI-compatible
|
||||
const style =
|
||||
provider === "aihubmix" &&
|
||||
baseUrl &&
|
||||
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
|
||||
? "openai"
|
||||
: PROVIDER_INFO[provider].modelList
|
||||
|
||||
switch (style) {
|
||||
case "anthropic": {
|
||||
const data = await getJson(
|
||||
`${base}/models?limit=1000`,
|
||||
{
|
||||
"x-api-key": apiKey ?? "",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
fetchFn,
|
||||
)
|
||||
models = (data.data ?? []).map((m: { id: string }) => ({
|
||||
id: m.id,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "google": {
|
||||
// The key goes in a header: in the URL it would end up in logs
|
||||
const data = await getJson(
|
||||
`${base}/models?pageSize=1000`,
|
||||
{ "x-goog-api-key": apiKey ?? "" },
|
||||
fetchFn,
|
||||
)
|
||||
models = (data.models ?? [])
|
||||
.filter((m: { supportedGenerationMethods?: string[] }) =>
|
||||
m.supportedGenerationMethods?.includes("generateContent"),
|
||||
)
|
||||
.map((m: { name: string }) => ({
|
||||
id: m.name.replace(/^models\//, ""),
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "ollama": {
|
||||
const api = base.endsWith("/api") ? base : `${base}/api`
|
||||
const data = await getJson(`${api}/tags`, bearer, fetchFn)
|
||||
models = (data.models ?? []).map((m: { name: string }) => ({
|
||||
id: m.name,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "openrouter": {
|
||||
const data = await getJson(`${base}/models`, bearer, fetchFn)
|
||||
models = (data.data ?? []).map(
|
||||
(m: { id: string; supported_parameters?: string[] }) => ({
|
||||
id: m.id,
|
||||
...(m.supported_parameters && {
|
||||
tools: m.supported_parameters.includes("tools"),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "gateway": {
|
||||
const { models: entries } = await createGateway({
|
||||
...(apiKey && { apiKey }),
|
||||
...(baseUrl && { baseURL: base }),
|
||||
fetch: fetchFn,
|
||||
}).getAvailableModels()
|
||||
models = entries
|
||||
.filter((m) => !m.modelType || m.modelType === "language")
|
||||
.map((m) => ({ id: m.id }))
|
||||
break
|
||||
}
|
||||
case "aihubmix": {
|
||||
const data = await getJson(AIHUBMIX_MODELS_ENDPOINT, {}, fetchFn)
|
||||
models = extractAihubmixModelIds(data).map((id) => ({ id }))
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (!base) {
|
||||
throw new ModelListError(
|
||||
`${PROVIDER_INFO[provider].label} needs a base URL to list its models.`,
|
||||
)
|
||||
}
|
||||
const data = await getJson(`${base}/models`, bearer, fetchFn)
|
||||
models = (data.data ?? [])
|
||||
.map((m: { id: string }) => ({ id: m.id }))
|
||||
.filter((m: ListedModel) => !NON_CHAT.test(m.id))
|
||||
}
|
||||
}
|
||||
|
||||
return models.map((m) => ({
|
||||
...m,
|
||||
tools: m.tools ?? getModelInfo(provider, m.id)?.tools,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Read a response body, giving up once it passes maxBytes, so a huge
|
||||
* download from a URL the client chose can't exhaust server memory.
|
||||
* Returns null when it is too large; the caller then aborts the request,
|
||||
* which ends the download.
|
||||
*/
|
||||
export async function readLimitedBody(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
if (Number(response.headers.get("content-length")) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
if (!response.body) return new ArrayBuffer(0)
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
// Not awaited: a copy of the body that Next.js keeps (its fetch
|
||||
// dedupe) can hold the cancel back until it is read
|
||||
reader.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return new Blob(chunks as BlobPart[]).arrayBuffer()
|
||||
}
|
||||
@@ -176,6 +176,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put(STORE_NAME, session)
|
||||
// The desktop app opens this port (this origin's chats) next launch
|
||||
window.electronAPI?.chatSaved?.().catch(() => {})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error)
|
||||
@@ -197,13 +199,18 @@ export async function deleteSession(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getSessionCount(): Promise<number> {
|
||||
if (!isIndexedDBAvailable()) return 0
|
||||
return (await readSessionCount()) ?? 0
|
||||
}
|
||||
|
||||
/** The number of saved chats, or null when it could not be read */
|
||||
export async function readSessionCount(): Promise<number | null> {
|
||||
if (!isIndexedDBAvailable()) return null
|
||||
try {
|
||||
const db = await getDB()
|
||||
return await db.count(STORE_NAME)
|
||||
} catch (error) {
|
||||
console.error("Failed to get session count:", error)
|
||||
return 0
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,3 +115,87 @@ export async function isPrivateUrl(urlString: string): Promise<boolean> {
|
||||
export function allowPrivateUrls(): boolean {
|
||||
return process.env.ALLOW_PRIVATE_URLS !== "false"
|
||||
}
|
||||
|
||||
/** A redirect the guard below refused; its text is safe to show */
|
||||
export class RedirectRefusedError extends Error {
|
||||
constructor(message = "Redirects are not allowed for custom base URLs") {
|
||||
super(message)
|
||||
this.name = "RedirectRefusedError"
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REDIRECTS = 5
|
||||
|
||||
// Dropped when a redirect goes to another origin: those fetch drops, and
|
||||
// the key headers of providers that do not use Authorization (Anthropic,
|
||||
// Google, Azure)
|
||||
const CREDENTIAL_HEADERS = [
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"x-api-key",
|
||||
"x-goog-api-key",
|
||||
"api-key",
|
||||
]
|
||||
|
||||
/**
|
||||
* A fetch for requests to a base URL the client chose. With private URLs
|
||||
* blocked, a public URL could still redirect the request to an internal
|
||||
* host, so redirects are refused. With private URLs allowed but the quota
|
||||
* on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the
|
||||
* server's: a public URL's redirects are followed only to public addresses,
|
||||
* or it could reach the server's own network uncounted. Undefined otherwise.
|
||||
*/
|
||||
export function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
const blockAll = !allowPrivateUrls()
|
||||
if (!blockAll && !process.env.DYNAMODB_QUOTA_TABLE) return undefined
|
||||
return async (input, init) => {
|
||||
let url = input instanceof Request ? input.url : String(input)
|
||||
let next = init
|
||||
// A request to a private address already counts as the server's
|
||||
let startsPrivate: boolean | undefined
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response = await fetch(url, { ...next, redirect: "manual" })
|
||||
const location = response.headers.get("location")
|
||||
if (response.status < 300 || response.status >= 400 || !location) {
|
||||
return response
|
||||
}
|
||||
if (blockAll) throw new RedirectRefusedError()
|
||||
startsPrivate ??= await isPrivateUrl(url)
|
||||
const from = new URL(url)
|
||||
url = new URL(location, url).toString()
|
||||
if (!startsPrivate && (await isPrivateUrl(url))) {
|
||||
throw new RedirectRefusedError(
|
||||
"Redirects to private addresses are not allowed",
|
||||
)
|
||||
}
|
||||
// The rest as fetch itself does it. Another origin gets no
|
||||
// credentials (the user's key, EdgeOne's cookies)
|
||||
const headers = new Headers(next?.headers)
|
||||
if (new URL(url).origin !== from.origin) {
|
||||
for (const name of CREDENTIAL_HEADERS) headers.delete(name)
|
||||
}
|
||||
next = { ...next, headers }
|
||||
// 303, and 301 or 302 after a POST, go on as a GET without the
|
||||
// body
|
||||
const method = (next.method ?? "GET").toUpperCase()
|
||||
if (
|
||||
response.status === 303 ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method === "POST")
|
||||
) {
|
||||
for (const name of [
|
||||
"content-type",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
"content-language",
|
||||
"content-location",
|
||||
]) {
|
||||
headers.delete(name)
|
||||
}
|
||||
next = { ...next, method: "GET", body: undefined }
|
||||
}
|
||||
}
|
||||
throw new RedirectRefusedError("Too many redirects")
|
||||
}
|
||||
}
|
||||
|
||||
+9
-32
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* System prompts for different AI models
|
||||
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
|
||||
*
|
||||
* Token counting utilities are in a separate file (token-counter.ts) to avoid
|
||||
* WebAssembly issues with Next.js server-side rendering.
|
||||
*/
|
||||
|
||||
import {
|
||||
SWIMLANE_EXAMPLE,
|
||||
TWO_EDGES_EXAMPLE,
|
||||
WAYPOINT_EXAMPLE,
|
||||
} from "@/packages/mcp-server/src/xml-examples.ts"
|
||||
|
||||
// Default system prompt (~1900 tokens) - works with all models
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
@@ -241,21 +244,7 @@ const EXTENDED_ADDITIONS = `
|
||||
|
||||
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
|
||||
\`\`\`xml
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
${SWIMLANE_EXAMPLE}
|
||||
\`\`\`
|
||||
|
||||
### append_diagram Details
|
||||
@@ -318,12 +307,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d
|
||||
|
||||
### Two edges between same nodes (CORRECT - no overlap):
|
||||
\`\`\`xml
|
||||
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
${TWO_EDGES_EXAMPLE}
|
||||
\`\`\`
|
||||
|
||||
### Edge with single waypoint (simple detour):
|
||||
@@ -342,14 +326,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d
|
||||
**WRONG:** Direct diagonal line crosses over Develop
|
||||
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||
\`\`\`xml
|
||||
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
${WAYPOINT_EXAMPLE}
|
||||
\`\`\`
|
||||
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||
|
||||
|
||||
+136
-21
@@ -32,6 +32,8 @@ export interface ModelConfig {
|
||||
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
|
||||
validated?: boolean // Has this model been validated
|
||||
validationError?: string // Error message if validation failed
|
||||
validationWarning?: string // Passed, but e.g. did not call a tool
|
||||
responseTime?: number // Milliseconds the last test took
|
||||
}
|
||||
|
||||
// Provider configuration
|
||||
@@ -120,22 +122,45 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
atlascloud: "openai",
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
/** How a provider lists its models (see lib/provider-models.ts) */
|
||||
export type ModelListStyle =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "ollama"
|
||||
| "openrouter"
|
||||
| "aihubmix"
|
||||
| "gateway"
|
||||
|
||||
// Provider metadata. apiKeyUrl is the page where users create a key.
|
||||
// modelList is missing where a key alone cannot list the models (Bedrock,
|
||||
// Vertex, Azure) or the list is not reliable (Doubao, MiniMax).
|
||||
export const PROVIDER_INFO: Record<
|
||||
ProviderName,
|
||||
{ label: string; defaultBaseUrl?: string }
|
||||
{
|
||||
label: string
|
||||
defaultBaseUrl?: string
|
||||
apiKeyUrl?: string
|
||||
modelList?: ModelListStyle
|
||||
}
|
||||
> = {
|
||||
openai: {
|
||||
label: "OpenAI",
|
||||
defaultBaseUrl: "https://api.openai.com/v1",
|
||||
apiKeyUrl: "https://platform.openai.com/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||
apiKeyUrl: "https://platform.claude.com/settings/keys",
|
||||
modelList: "anthropic",
|
||||
},
|
||||
google: {
|
||||
label: "Google",
|
||||
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
apiKeyUrl: "https://aistudio.google.com/apikey",
|
||||
modelList: "google",
|
||||
},
|
||||
vertexai: { label: "Google Vertex AI" },
|
||||
azure: {
|
||||
@@ -146,77 +171,114 @@ export const PROVIDER_INFO: Record<
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
defaultBaseUrl: "https://ollama.com/api",
|
||||
apiKeyUrl: "https://ollama.com/settings/keys",
|
||||
modelList: "ollama",
|
||||
},
|
||||
openrouter: {
|
||||
label: "OpenRouter",
|
||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKeyUrl: "https://openrouter.ai/keys",
|
||||
modelList: "openrouter",
|
||||
},
|
||||
aihubmix: {
|
||||
label: "AIHubMix",
|
||||
defaultBaseUrl: "https://aihubmix.com/v1",
|
||||
apiKeyUrl: "https://aihubmix.com/token",
|
||||
modelList: "aihubmix",
|
||||
},
|
||||
deepseek: {
|
||||
label: "DeepSeek",
|
||||
defaultBaseUrl: "https://api.deepseek.com/v1",
|
||||
apiKeyUrl: "https://platform.deepseek.com/api_keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
siliconflow: {
|
||||
label: "SiliconFlow",
|
||||
defaultBaseUrl: "https://api.siliconflow.cn/v1",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
|
||||
modelList: "openai",
|
||||
},
|
||||
sglang: {
|
||||
label: "SGLang",
|
||||
defaultBaseUrl: "http://127.0.0.1:8000/v1",
|
||||
modelList: "openai",
|
||||
},
|
||||
gateway: {
|
||||
label: "AI Gateway",
|
||||
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
|
||||
apiKeyUrl: "https://vercel.com/ai-gateway",
|
||||
modelList: "gateway",
|
||||
},
|
||||
edgeone: { label: "EdgeOne Pages" },
|
||||
doubao: {
|
||||
label: "Doubao (ByteDance)",
|
||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
|
||||
},
|
||||
modelscope: {
|
||||
label: "ModelScope",
|
||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
|
||||
modelList: "openai",
|
||||
},
|
||||
glm: {
|
||||
label: "GLM (Zhipu)",
|
||||
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
||||
modelList: "openai",
|
||||
},
|
||||
qwen: {
|
||||
label: "Qwen (Alibaba)",
|
||||
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
|
||||
modelList: "openai",
|
||||
},
|
||||
qiniu: {
|
||||
label: "Qiniu",
|
||||
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||
apiKeyUrl: "https://www.qiniu.com/ai/models",
|
||||
modelList: "openai",
|
||||
},
|
||||
kimi: {
|
||||
label: "Kimi (Moonshot)",
|
||||
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
minimax: {
|
||||
label: "MiniMax",
|
||||
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||
apiKeyUrl:
|
||||
"https://platform.minimaxi.com/user-center/basic-information/interface-key",
|
||||
},
|
||||
novita: {
|
||||
label: "Novita AI",
|
||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||
apiKeyUrl: "https://novita.ai/dashboard/key",
|
||||
modelList: "openai",
|
||||
},
|
||||
mimo: {
|
||||
label: "MiMo (Xiaomi)",
|
||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
atlascloud: {
|
||||
label: "Atlas Cloud",
|
||||
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
||||
apiKeyUrl: "https://www.atlascloud.ai/console/api-keys",
|
||||
modelList: "openai",
|
||||
},
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
openai: [
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-6-astra",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-pro",
|
||||
@@ -230,7 +292,13 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
anthropic: [
|
||||
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
|
||||
// Claude 5 series (latest)
|
||||
"claude-opus-5-5",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
// Claude 4.8 / 4.7 / 4.6 series (dateless pinned IDs)
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
@@ -279,39 +347,47 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"o3",
|
||||
"o4-mini",
|
||||
],
|
||||
// Newer models only answer through an inference profile id (the region
|
||||
// prefix). Each id here was called once on 2026-10-04.
|
||||
bedrock: [
|
||||
// Anthropic Claude
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
// Anthropic Claude ("global." works from any region)
|
||||
"global.anthropic.claude-opus-5-5",
|
||||
"global.anthropic.claude-sonnet-5-5",
|
||||
"global.anthropic.claude-fable-5-1",
|
||||
"global.anthropic.claude-opus-5",
|
||||
"global.anthropic.claude-sonnet-5",
|
||||
"global.anthropic.claude-opus-4-8",
|
||||
"global.anthropic.claude-opus-4-7",
|
||||
"global.anthropic.claude-sonnet-4-6",
|
||||
"global.anthropic.claude-opus-4-6-v1",
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"global.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
// Amazon Nova
|
||||
"amazon.nova-2-lite-v1:0",
|
||||
"amazon.nova-premier-v1:0",
|
||||
"us.amazon.nova-2-lite-v1:0",
|
||||
"amazon.nova-pro-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
// Meta Llama
|
||||
"meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"meta.llama4-scout-17b-instruct-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"us.meta.llama4-maverick-17b-instruct-v1:0",
|
||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
||||
"us.meta.llama3-3-70b-instruct-v1:0",
|
||||
// Mistral
|
||||
"mistral.mistral-large-3-675b-instruct",
|
||||
"mistral.pixtral-large-2502-v1:0",
|
||||
"us.mistral.pixtral-large-2502-v1:0",
|
||||
],
|
||||
openrouter: [
|
||||
// Anthropic
|
||||
"anthropic/claude-opus-5.5",
|
||||
"anthropic/claude-sonnet-5.5",
|
||||
"anthropic/claude-fable-5.1",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
// OpenAI
|
||||
"openai/gpt-6.1-sol",
|
||||
"openai/gpt-6-luna",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.4-mini",
|
||||
@@ -400,6 +476,10 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"default",
|
||||
],
|
||||
gateway: [
|
||||
"anthropic/claude-opus-5.5",
|
||||
"anthropic/claude-sonnet-5.5",
|
||||
"openai/gpt-6.1-sol",
|
||||
"openai/gpt-6-luna",
|
||||
"openai/gpt-5.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
@@ -529,3 +609,38 @@ export function findModelById(
|
||||
): FlattenedModel | undefined {
|
||||
return flattenModels(config).find((m) => m.id === modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A base URL the way the SDKs expect it: no spaces, no trailing slash, and
|
||||
* no endpoint path users often paste along (".../v1/chat/completions"),
|
||||
* which the SDK would append a second time.
|
||||
*/
|
||||
export function normalizeBaseUrl(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/(?:chat\/completions|completions|messages|responses)$/, "")
|
||||
}
|
||||
|
||||
/** Where a chat request goes for a base URL, or null when the SDK decides */
|
||||
export function chatRequestUrl(
|
||||
provider: ProviderName,
|
||||
baseUrl: string,
|
||||
): string | null {
|
||||
const url = normalizeBaseUrl(baseUrl)
|
||||
if (!url) return null
|
||||
if (provider === "anthropic") return `${url}/messages`
|
||||
// These SDKs build their own paths (or, for MiniMax, pick the protocol
|
||||
// from the URL)
|
||||
const ownPaths: ProviderName[] = [
|
||||
"google",
|
||||
"vertexai",
|
||||
"azure",
|
||||
"bedrock",
|
||||
"ollama",
|
||||
"gateway",
|
||||
"minimax",
|
||||
"edgeone",
|
||||
]
|
||||
return ownPaths.includes(provider) ? null : `${url}/chat/completions`
|
||||
}
|
||||
|
||||
+9
-1428
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -10,9 +10,11 @@ const nextConfig: NextConfig = {
|
||||
env: {
|
||||
APP_VERSION: packageJson.version,
|
||||
},
|
||||
// Include instrumentation.ts in standalone build for Langfuse telemetry
|
||||
// Include instrumentation.ts in standalone build for Langfuse telemetry,
|
||||
// and the shape library docs get_shape_library reads at run time
|
||||
outputFileTracingIncludes: {
|
||||
"*": ["./instrumentation.ts"],
|
||||
"/api/chat": ["./docs/shape-libraries/*.md"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Generated
+117
-218
@@ -9,16 +9,16 @@
|
||||
"version": "0.4.16",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||
"@ai-sdk/anthropic": "^3.0.0",
|
||||
"@ai-sdk/azure": "^3.0.0",
|
||||
"@ai-sdk/deepseek": "^2.0.0",
|
||||
"@ai-sdk/gateway": "^3.0.0",
|
||||
"@ai-sdk/google": "^3.0.0",
|
||||
"@ai-sdk/google-vertex": "^4.0.16",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.1",
|
||||
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.191",
|
||||
"@ai-sdk/anthropic": "^3.0.127",
|
||||
"@ai-sdk/azure": "^3.0.133",
|
||||
"@ai-sdk/deepseek": "^2.0.71",
|
||||
"@ai-sdk/google": "^3.0.130",
|
||||
"@ai-sdk/google-vertex": "^4.0.210",
|
||||
"@ai-sdk/openai": "^3.0.124",
|
||||
"@ai-sdk/openai-compatible": "^2.0.81",
|
||||
"@ai-sdk/react": "^3.0.303",
|
||||
"@aihubmix/ai-sdk-provider": "^2.2.1",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.10.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"ai": "^6.0.1",
|
||||
"ai": "^6.0.300",
|
||||
"base-64": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -56,7 +56,7 @@
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.0.7",
|
||||
"ollama-ai-provider-v2": "^3.0.0",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.2",
|
||||
@@ -66,6 +66,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
@@ -123,15 +124,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ai-sdk/amazon-bedrock": {
|
||||
"version": "4.0.113",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.113.tgz",
|
||||
"integrity": "sha512-qoeF2ghkYqHY4u68rasZopYsRuGnRwgqSfe6rhC/jM6W73Z7TU5v9QcfDYKt9dgp19YHY9EqiX3SXVC+uPGkRQ==",
|
||||
"version": "4.0.191",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.191.tgz",
|
||||
"integrity": "sha512-7wgtq8On8oqmofGIGtYl8EOEbawnfiPbIbWpu2ksGNF065J8mbpE6DhkHNv0dpPbvnn4txL4sflUwUPwHw7/SQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.81",
|
||||
"@ai-sdk/openai": "3.0.68",
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27",
|
||||
"@ai-sdk/anthropic": "3.0.127",
|
||||
"@ai-sdk/openai": "3.0.124",
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57",
|
||||
"@smithy/eventstream-codec": "^4.0.1",
|
||||
"@smithy/util-utf8": "^4.0.0",
|
||||
"aws4fetch": "^1.0.20"
|
||||
@@ -143,75 +144,14 @@
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/anthropic": {
|
||||
"version": "3.0.81",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.81.tgz",
|
||||
"integrity": "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/openai": {
|
||||
"version": "3.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.68.tgz",
|
||||
"integrity": "sha512-FCs/DPr4M95UyZ/ABHJmTmCEYRCka/4J0Bna0nsd78QCdGIS0X/zhn+fVzB7mZJo7464uOWYUjROx9PGNGOb0w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
|
||||
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
|
||||
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/anthropic": {
|
||||
"version": "3.0.47",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.47.tgz",
|
||||
"integrity": "sha512-E6Z3i/xvxGDxRskMMbuX9+xDK4l5LesrP2O7YQ0CcbAkYP25qTo/kYGf/AsJrLkNIY23HeO/kheUWtG1XZllDA==",
|
||||
"version": "3.0.127",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.127.tgz",
|
||||
"integrity": "sha512-Inff1DmPRVWi6QGr9YL2wiA4maILZuve9I8id/O4keQEoq6fJv6EfvkrSFw/nNvm2jiTcIUfDrpczeHTuhl9mg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -221,14 +161,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/azure": {
|
||||
"version": "3.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.34.tgz",
|
||||
"integrity": "sha512-nnOFtgvZYOa6XIeAm18i56NX77Yu4Bd+Tnbt85LGUEqwJFR54kFTlR1nm3BAJCphHrmQteJd1P3QErtyoXig8A==",
|
||||
"version": "3.0.133",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.133.tgz",
|
||||
"integrity": "sha512-iVCT1q6bRPWjzisu7Xy1bc1yWtegd3WSZzmi4+jkiFTKTxir703F5jH8mWPG0hPnqW/OjdkOrSzcQ063j7T/ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "3.0.33",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15"
|
||||
"@ai-sdk/deepseek": "2.0.71",
|
||||
"@ai-sdk/openai": "3.0.124",
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -238,13 +179,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/deepseek": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.20.tgz",
|
||||
"integrity": "sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA==",
|
||||
"version": "2.0.71",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.71.tgz",
|
||||
"integrity": "sha512-2uLtZBgONfzEP7tZOg23sBzi33n1/gcW1zLNNw96/PLt9vdqDYIkGQnKRd6F+/0Wwy77jVulU+gzpiudk3f0UQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -254,14 +195,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "3.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.55.tgz",
|
||||
"integrity": "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw==",
|
||||
"version": "3.0.209",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.209.tgz",
|
||||
"integrity": "sha512-CjCBzC35lRZ0LnUYLDfTB92p0Fr1Fu9Wrivc+LI0u3ox/DyZ0wPOHjt8DKBKHD/R4aZ86+JCFFuD9f8MFjAQFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15",
|
||||
"@vercel/oidc": "3.1.0"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57",
|
||||
"@vercel/oidc": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -271,13 +212,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/google": {
|
||||
"version": "3.0.31",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.31.tgz",
|
||||
"integrity": "sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==",
|
||||
"version": "3.0.130",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.130.tgz",
|
||||
"integrity": "sha512-DOhGfFT667LbopxMZU7kIiT4K8F2RNN8ziwmOxAfMbp3xTvAaxQ4ynPKNWBVXcCkGYDmzPb7QiazN1IRX8y3lw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -287,15 +228,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/google-vertex": {
|
||||
"version": "4.0.63",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.63.tgz",
|
||||
"integrity": "sha512-/RNi6KSB4162DDYeXHUKQc5jLPmiJMkhTswLwbfPUEPyyFjbxpBWgeAk/vS/u8jxT4IPyYQ8cD/uyvJibmtmww==",
|
||||
"version": "4.0.210",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.210.tgz",
|
||||
"integrity": "sha512-7Jj41iWsTFUcv6pzji5AlW2QaMJt/PEMU/zwqji2H7U52WvZE345kyo+nKRJMFtHgSVqFmzmMUIM9gyeaXKRtw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.47",
|
||||
"@ai-sdk/google": "3.0.31",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15",
|
||||
"@ai-sdk/anthropic": "3.0.127",
|
||||
"@ai-sdk/google": "3.0.130",
|
||||
"@ai-sdk/openai-compatible": "2.0.81",
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57",
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -306,13 +248,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "3.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.33.tgz",
|
||||
"integrity": "sha512-O/8SVKAiwFHkGAUfBnrLb7L2IjbpP9ySWbmOktOfa0KtzutZkmKNrJ5CtB5dj+lwuENbOuZeRsnsZdOjar7hig==",
|
||||
"version": "3.0.124",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.124.tgz",
|
||||
"integrity": "sha512-7DpRUPXzJ+S6XasEJBojzFCs04J3rNPs0FZFF3PEeix49IggZAwsMrqscUwB/+05+87r01SS/jeopQGOOL4AEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -322,42 +264,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai-compatible": {
|
||||
"version": "2.0.48",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz",
|
||||
"integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==",
|
||||
"version": "2.0.81",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.81.tgz",
|
||||
"integrity": "sha512-L14Jd0lAFKNM42l9TxlpwkNiyMlSvSvGySLc9GN4FB0qOo4/WmYOeLyn/CPhghIr0K4O6vH1y0nChxGljq2ccg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@ai-sdk/provider-utils": "4.0.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
|
||||
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
|
||||
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.10",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -367,9 +280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
||||
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
|
||||
"version": "3.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.18.tgz",
|
||||
"integrity": "sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
@@ -379,30 +292,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
|
||||
"integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
|
||||
"version": "4.0.57",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.57.tgz",
|
||||
"integrity": "sha512-89a7sPvZqXsP3PigcAQLWntu/duIVEcN/RyChAB+j6p3C7NwW767sVttvL8r0+BetTg63uAXQoYHCZZueQKBSw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.6"
|
||||
"eventsource-parser": "^3.0.8",
|
||||
"undici": "^6.28.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider-utils/node_modules/undici": {
|
||||
"version": "6.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
|
||||
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/react": {
|
||||
"version": "3.0.102",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.102.tgz",
|
||||
"integrity": "sha512-WPSYJxk/HM3SWhhxE+WrLhIMbaRLpDHWWYzUlrptXowwEoyliYBYZAkzip/gV8hybT2NyrWnsUh5KRO5iBdsQA==",
|
||||
"version": "3.0.303",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.303.tgz",
|
||||
"integrity": "sha512-PeTBn25x8QxfIIpdUDOpWHfiIZxMRJCw04PhWNHI9hl+gtu+zGwAxWU+sy3PH/f/oEBBAp7eDDUDDjiuqwcw6g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider-utils": "4.0.15",
|
||||
"ai": "6.0.100",
|
||||
"@ai-sdk/provider-utils": "4.0.57",
|
||||
"ai": "6.0.300",
|
||||
"swr": "^2.2.5",
|
||||
"throttleit": "2.1.0"
|
||||
},
|
||||
@@ -414,9 +337,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aihubmix/ai-sdk-provider": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.1.0.tgz",
|
||||
"integrity": "sha512-AqK10PV5B4zWFBav5PRUhrWGYTHjC0s6cIbd4v9hBo6D9SqKv5o41B+dkl6IeIPrnZrMGLEE9Mn5R+VaEkhbdg==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.2.1.tgz",
|
||||
"integrity": "sha512-VUkSYFbrigs5ZF44LRSHEI/q1fZyLkExWvrgBG4w0ysGEE/XXt7fi9NgMG9UH1PQVBgnc/PP03CUhTM1nhmGxQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.0",
|
||||
@@ -5890,9 +5813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openrouter/ai-sdk-provider": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.8.1.tgz",
|
||||
"integrity": "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ==",
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.10.0.tgz",
|
||||
"integrity": "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9326,9 +9249,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@vercel/oidc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
|
||||
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
|
||||
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
@@ -9594,15 +9517,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ai": {
|
||||
"version": "6.0.100",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.100.tgz",
|
||||
"integrity": "sha512-BIxhG7M7wvcWCF+IEnZi7WpkRLOM3jR2vJ0mMuohl2UB2i1R/ZUa1cHFel1xI8nWvyUpOoQXKqsM0BAH50EYSQ==",
|
||||
"version": "6.0.300",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.300.tgz",
|
||||
"integrity": "sha512-ZBT30eQTy6exP+EWJJxm3DMb34m9td8STy+hfnvKdjhyEQoM8vQN5YfFLTRVVTCONHoM+y5K+Tudb6PTFrpikA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "3.0.55",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.15",
|
||||
"@opentelemetry/api": "1.9.0"
|
||||
"@ai-sdk/gateway": "3.0.209",
|
||||
"@ai-sdk/provider": "3.0.18",
|
||||
"@ai-sdk/provider-utils": "4.0.57",
|
||||
"@opentelemetry/api": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9611,15 +9534,6 @@
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ai/node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -19589,39 +19503,26 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ollama-ai-provider-v2": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.5.0.tgz",
|
||||
"integrity": "sha512-+s/aYIYa91z2Vk3AkGAz3BaPAQ0flS2eFZD3BN2mD/N6W6YQbcookyu6pc2cbc8SP5VGpNB857WJ0eHDjKXsXw==",
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.6.0.tgz",
|
||||
"integrity": "sha512-1Om3FVJYhBwkAr5kQ+BX1s/tdVdtVdoFQWrX4PBQHDHPISyGt24CjhtggEjUYpy5ait0YeVfZwEpIYjgD8Ih7Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@ai-sdk/provider-utils": "^4.0.19"
|
||||
"@ai-sdk/provider": "^3.0.10",
|
||||
"@ai-sdk/provider-utils": "^4.0.27"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "Buy Me a Coffee",
|
||||
"url": "https://buymeacoffee.com/nordwestt"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ai": "^5.0.0 || ^6.0.0",
|
||||
"zod": "^4.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/ollama-ai-provider-v2/node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "4.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz",
|
||||
"integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"eventsource-parser": "^3.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -21478,7 +21379,6 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
@@ -25261,7 +25161,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
|
||||
+14
-13
@@ -31,16 +31,16 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||
"@ai-sdk/anthropic": "^3.0.0",
|
||||
"@ai-sdk/azure": "^3.0.0",
|
||||
"@ai-sdk/deepseek": "^2.0.0",
|
||||
"@ai-sdk/gateway": "^3.0.0",
|
||||
"@ai-sdk/google": "^3.0.0",
|
||||
"@ai-sdk/google-vertex": "^4.0.16",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.1",
|
||||
"@aihubmix/ai-sdk-provider": "^2.1.0",
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.191",
|
||||
"@ai-sdk/anthropic": "^3.0.127",
|
||||
"@ai-sdk/azure": "^3.0.133",
|
||||
"@ai-sdk/deepseek": "^2.0.71",
|
||||
"@ai-sdk/google": "^3.0.130",
|
||||
"@ai-sdk/google-vertex": "^4.0.210",
|
||||
"@ai-sdk/openai": "^3.0.124",
|
||||
"@ai-sdk/openai-compatible": "^2.0.81",
|
||||
"@ai-sdk/react": "^3.0.303",
|
||||
"@aihubmix/ai-sdk-provider": "^2.2.1",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||
"@openrouter/ai-sdk-provider": "^2.10.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
@@ -66,7 +66,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@xmldom/xmldom": "^0.9.8",
|
||||
"ai": "^6.0.1",
|
||||
"ai": "^6.0.300",
|
||||
"base-64": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.0.7",
|
||||
"ollama-ai-provider-v2": "^3.0.0",
|
||||
"ollama-ai-provider-v2": "^3.6.0",
|
||||
"pako": "^2.1.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.1.2",
|
||||
@@ -88,6 +88,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
|
||||
@@ -25,10 +25,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
## Features
|
||||
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
|
||||
- **Version History**: Restore previous diagram versions with visual thumbnails
|
||||
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
|
||||
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
|
||||
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
|
||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||
- **Export**: Save diagrams as `.drawio` files
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
|
||||
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
|
||||
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
|
||||
- **Self-contained**: Embedded server, no external dependencies required
|
||||
|
||||
## Use Case Examples
|
||||
@@ -72,11 +75,16 @@ between user, client app, auth server, and resource server
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `start_session` | Opens browser with real-time diagram preview |
|
||||
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
|
||||
| `get_drawing_guide` | Return the drawing rules again |
|
||||
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
|
||||
| `create_new_diagram` | Create a new diagram from XML |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations |
|
||||
| `load_diagram` | Load a `.drawio` file from disk |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
|
||||
| `get_diagram` | Get the current diagram XML |
|
||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
||||
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -95,6 +103,8 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
|
||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off |
|
||||
| `DEBUG` | unset | Set to `true` to log debug messages |
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -104,27 +104,41 @@ Use the standard MCP configuration with:
|
||||
## Features
|
||||
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
||||
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
|
||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions
|
||||
- **Export**: Save diagrams as `.drawio` files
|
||||
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
|
||||
- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
|
||||
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
|
||||
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails
|
||||
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
|
||||
- **Multi-page**: List, add, rename, and delete pages, and edit any page
|
||||
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, so it survives a restart of the MCP client
|
||||
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
|
||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `start_session` | Opens browser with real-time diagram preview |
|
||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
||||
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
|
||||
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
|
||||
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
|
||||
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
|
||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||
| `get_diagram` | Get the current diagram XML |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
||||
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
||||
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||
| `add_page` | Append a new page without touching existing ones |
|
||||
| `rename_page` | Rename a page |
|
||||
| `delete_page` | Delete a page (refuses to delete the last one) |
|
||||
|
||||
## Continue a Diagram Later
|
||||
|
||||
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself.
|
||||
|
||||
The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
@@ -154,6 +168,8 @@ Use the standard MCP configuration with:
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
|
||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. |
|
||||
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
|
||||
|
||||
### Private Deployment (Self-hosted draw.io)
|
||||
|
||||
@@ -179,7 +195,7 @@ You can deploy your own draw.io instance using the official Docker image:
|
||||
docker run -d -p 8080:8080 jgraph/drawio
|
||||
```
|
||||
|
||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
|
||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -208,6 +224,10 @@ Call `start_session` first to open the browser window.
|
||||
|
||||
Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.
|
||||
|
||||
### Screenshot or PNG/SVG export times out
|
||||
|
||||
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
Generated
+28
-3
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"linkedom": "^0.18.0",
|
||||
"open": "^11.0.0",
|
||||
"pako": "^2.2.0",
|
||||
"saxes": "^6.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
@@ -20,6 +21,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pako": "^2.0.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
@@ -909,6 +911,13 @@
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
@@ -2601,6 +2610,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -8,7 +8,8 @@
|
||||
"next-ai-drawio-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && node scripts/copy-assets.mjs",
|
||||
"check-package": "node scripts/check-package.mjs",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
@@ -38,14 +39,16 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"linkedom": "^0.18.0",
|
||||
"open": "^11.0.0",
|
||||
"pako": "^2.2.0",
|
||||
"saxes": "^6.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pako": "^2.0.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Fail if the npm package would miss files the server reads at runtime.
|
||||
// Tests run from src/ (tsx) and cannot notice a broken dist/ copy step.
|
||||
// Run after `npm run build`.
|
||||
import { execSync } from "node:child_process"
|
||||
|
||||
const REQUIRED = [
|
||||
"dist/index.js",
|
||||
"dist/shape-libraries/aws4.md",
|
||||
"dist/preview/index.html",
|
||||
"dist/preview/preview.css",
|
||||
"dist/preview/preview.js",
|
||||
]
|
||||
|
||||
const [pack] = JSON.parse(
|
||||
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
||||
)
|
||||
const files = new Set(pack.files.map((f) => f.path))
|
||||
const missing = REQUIRED.filter((f) => !files.has(f))
|
||||
if (missing.length > 0) {
|
||||
console.error(`npm package is missing: ${missing.join(", ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`npm package OK (${files.size} files)`)
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copy non-TypeScript assets into dist/ after tsc, so they ship in the npm
|
||||
// package ("files": ["dist"]).
|
||||
import { cpSync, mkdirSync, readdirSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
||||
// Shape library docs live at the repository root, shared with the web app
|
||||
const libSrc = join(pkg, "../../docs/shape-libraries")
|
||||
const libDest = join(pkg, "dist/shape-libraries")
|
||||
mkdirSync(libDest, { recursive: true })
|
||||
for (const file of readdirSync(libSrc)) {
|
||||
if (file.endsWith(".md") && file !== "README.md") {
|
||||
cpSync(join(libSrc, file), join(libDest, file))
|
||||
}
|
||||
}
|
||||
|
||||
// Browser preview page (HTML, CSS and script)
|
||||
cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), {
|
||||
recursive: true,
|
||||
})
|
||||
@@ -7,9 +7,10 @@
|
||||
* first page is targeted (the "active page by convention" — see pages.ts).
|
||||
*/
|
||||
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import { log } from "./logger.js"
|
||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
||||
import { decompressPageContent } from "./load-diagram.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts"
|
||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
@@ -33,6 +34,44 @@ export interface ApplyOperationsResult {
|
||||
// on the wrapper, so the wrapper is treated as the cell.
|
||||
const CELL_SELECTOR = "mxCell, UserObject, object"
|
||||
|
||||
/**
|
||||
* Return the <root> of a <diagram> page, creating it when missing. An empty
|
||||
* page gets a blank model with the "0" and "1" root cells; a page whose text
|
||||
* is draw.io's compressed format is decompressed in place. Returns null if
|
||||
* the text is neither empty nor decompressible.
|
||||
*/
|
||||
function ensurePageRoot(doc: Document, page: Element): Element | null {
|
||||
const existing = page.querySelector("root")
|
||||
if (existing) return existing
|
||||
|
||||
let model = page.querySelector("mxGraphModel")
|
||||
if (!model) {
|
||||
const text = page.textContent?.trim() ?? ""
|
||||
if (text) {
|
||||
const xml = decompressPageContent(text)
|
||||
if (!xml || getXmlSyntaxError(xml)) return null
|
||||
const parsed = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (parsed.documentElement?.tagName !== "mxGraphModel") return null
|
||||
page.textContent = ""
|
||||
model = page.appendChild(
|
||||
doc.importNode(parsed.documentElement, true),
|
||||
) as Element
|
||||
const decompressedRoot = model.querySelector("root")
|
||||
if (decompressedRoot) return decompressedRoot
|
||||
} else {
|
||||
model = page.appendChild(doc.createElement("mxGraphModel"))
|
||||
}
|
||||
}
|
||||
|
||||
const blank = new DOMParser().parseFromString(
|
||||
`<root><mxCell id="0"/><mxCell id="1" parent="0"/></root>`,
|
||||
"text/xml",
|
||||
)
|
||||
return model.appendChild(
|
||||
doc.importNode(blank.documentElement, true),
|
||||
) as Element
|
||||
}
|
||||
|
||||
/** Read parent/source/target, which a wrapped cell keeps on its inner mxCell. */
|
||||
function cellAttr(cell: Element, name: string): string | null {
|
||||
const inner =
|
||||
@@ -98,7 +137,7 @@ export function applyDiagramOperations(
|
||||
],
|
||||
}
|
||||
}
|
||||
root = found.element.querySelector("root")
|
||||
root = ensurePageRoot(doc as unknown as Document, found.element)
|
||||
if (!root) {
|
||||
const pageId =
|
||||
found.element.getAttribute("id") || `(index ${found.index})`
|
||||
@@ -108,7 +147,7 @@ export function applyDiagramOperations(
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page "${pageId}" has no <root> element`,
|
||||
message: `Page "${pageId}" has no <root> element and its content could not be decompressed`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -146,7 +185,11 @@ export function applyDiagramOperations(
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll(CELL_SELECTOR).forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
if (id) cellMap.set(id, cell)
|
||||
// A wrapped mxCell may repeat its wrapper's id; the wrapper is the cell
|
||||
const wrapped =
|
||||
cell.tagName === "mxCell" &&
|
||||
/^(UserObject|object)$/.test(cell.parentElement?.tagName ?? "")
|
||||
if (id && !wrapped) cellMap.set(id, cell)
|
||||
})
|
||||
// Ids deleted so far in this batch; deleting one again is a no-op
|
||||
const deletedIds = new Set<string>()
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
/**
|
||||
* DOM setup for Node.
|
||||
* DOM setup for Node. The XML helpers use the global DOMParser and
|
||||
* XMLSerializer, which the browser has and Node gets from here.
|
||||
*
|
||||
* linkedom gives us a DOM with querySelector, but it is lenient: it never
|
||||
* reports syntax errors (no <parsererror>), and its serializer writes raw
|
||||
* newlines inside attribute values, which the browser reads back as spaces.
|
||||
* saxes, a strict XML parser, checks well-formedness the way draw.io's
|
||||
* DOMParser will, and serializeXml writes attribute values safely.
|
||||
* reports syntax errors (no <parsererror>; xml-syntax.ts checks them), and
|
||||
* its serializer writes raw newlines inside attribute values, which the
|
||||
* browser reads back as spaces. serializeXml writes attribute values safely.
|
||||
*/
|
||||
import { DOMParser } from "linkedom"
|
||||
import { SaxesParser } from "saxes"
|
||||
|
||||
/**
|
||||
* Returns the first XML syntax error as "line:column: message", or null if
|
||||
* the XML is well-formed. Surrounding whitespace is ignored because every
|
||||
* caller trims before the XML reaches the browser.
|
||||
*/
|
||||
export function getXmlSyntaxError(xml: string): string | null {
|
||||
let error: string | null = null
|
||||
const parser = new SaxesParser()
|
||||
parser.on("error", (err) => {
|
||||
error ??= err.message
|
||||
})
|
||||
parser.write(xml.trim()).close()
|
||||
return error
|
||||
}
|
||||
|
||||
const ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
@@ -76,6 +60,31 @@ export function serializeXml(node: Node): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XML parsers read a literal tab or line break inside an attribute value as
|
||||
* a space (a line break written as 
 stays one). linkedom keeps it, and
|
||||
* serializeXml would then write it as a real line break, so an edit would
|
||||
* change labels it never touched. Applied to the text before linkedom.
|
||||
*/
|
||||
function normalizeAttributeWhitespace(xml: string): string {
|
||||
return xml.replace(
|
||||
/<[A-Za-z][^"'<>]*(?:(?:"[^"]*"|'[^']*')[^"'<>]*)*>/g,
|
||||
(tag) =>
|
||||
tag.replace(/"[^"]*"|'[^']*'/g, (value) =>
|
||||
value.replace(/\r\n|[\t\n\r]/g, " "),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
class XmlDomParser extends DOMParser {
|
||||
parseFromString(text: string, type: string) {
|
||||
return super.parseFromString(
|
||||
type.includes("xml") ? normalizeAttributeWhitespace(text) : text,
|
||||
type as any,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: Node): string {
|
||||
return serializeXml(node)
|
||||
@@ -84,6 +93,6 @@ class XMLSerializerPolyfill {
|
||||
|
||||
/** Install the DOMParser and XMLSerializer globals the XML helpers use. */
|
||||
export function installDomPolyfill(): void {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
;(globalThis as any).DOMParser = XmlDomParser
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Drawing guide for the model, returned by start_session, get_drawing_guide
|
||||
* and the diagram-workflow prompt.
|
||||
*
|
||||
* Adapted from the web app's system prompt (lib/system-prompts.ts) and its
|
||||
* tool descriptions (app/api/chat/route.ts). When drawing rules change there,
|
||||
* update this file too.
|
||||
*/
|
||||
|
||||
import {
|
||||
indent,
|
||||
SWIMLANE_EXAMPLE,
|
||||
TWO_EDGES_EXAMPLE,
|
||||
WAYPOINT_EXAMPLE,
|
||||
} from "./xml-examples.ts"
|
||||
|
||||
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||
|
||||
## Workflow
|
||||
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
||||
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
||||
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
||||
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
||||
- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes.
|
||||
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes.
|
||||
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
|
||||
|
||||
## The XML you send
|
||||
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1".
|
||||
|
||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
|
||||
|
||||
Rules (XML that breaks them is rejected):
|
||||
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
|
||||
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
|
||||
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container.
|
||||
4. Edge source and target must reference existing cell ids.
|
||||
5. Escape special characters in attribute values: < for <, > for >, & for &, " for ".
|
||||
6. NEVER include XML comments (<!-- -->). draw.io strips them.
|
||||
7. In tool arguments (JSON), every " inside the XML must be escaped as \\".
|
||||
|
||||
Containers and swimlanes: children use the container id as parent and coordinates relative to the container.
|
||||
|
||||
${indent(SWIMLANE_EXAMPLE)}
|
||||
|
||||
## Layout
|
||||
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
|
||||
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
|
||||
- Start near x=40, y=40 and keep elements grouped closely.
|
||||
- For large diagrams, stack vertically or use a grid instead of spreading wide.
|
||||
|
||||
## Edge routing rules
|
||||
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
|
||||
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
|
||||
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;".
|
||||
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
|
||||
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
|
||||
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
|
||||
Rule 7: Use natural connection points. Never connect at corners (both X and Y 0 or 1). Top-to-bottom flow: exitY=1 into entryY=0. Left-to-right flow: exitX=1 into entryX=0. Diagonal: the side closest to the target.
|
||||
|
||||
Before sending XML, check:
|
||||
1. Does any edge cross a shape that is not its source or target? Add waypoints.
|
||||
2. Do two edges share a path? Change their exit or entry points.
|
||||
3. Is any connection point at a corner? Use the middle of a side.
|
||||
4. Could moving shapes remove edge crossings? Revise the layout.
|
||||
|
||||
Two edges between the same nodes:
|
||||
|
||||
${indent(TWO_EDGES_EXAMPLE)}
|
||||
|
||||
Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (right, bottom) connects to Main (center, top) while Develop (center, middle) is in between, so the edge goes right to x=750 first, then up, and enters Main from the right:
|
||||
|
||||
${indent(WAYPOINT_EXAMPLE)}
|
||||
|
||||
## Styles
|
||||
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1;
|
||||
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right
|
||||
- Animated connectors: add flowAnimation=1 to the edge style.
|
||||
|
||||
## Minimal style
|
||||
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
|
||||
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
|
||||
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;".
|
||||
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children.
|
||||
- Keep at least 50px between elements, and follow all edge routing rules strictly.
|
||||
|
||||
## Editing with edit_diagram
|
||||
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id.
|
||||
- add inserts a new cell with a new id. One cell per operation.
|
||||
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
|
||||
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
|
||||
- If the diagram is large, change it with edit_diagram instead of redrawing it.
|
||||
|
||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
|
||||
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
|
||||
`
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Core of the edit_diagram tool, kept free of session state so it can be
|
||||
* unit tested.
|
||||
*
|
||||
* All-or-nothing, like the web app (hooks/use-diagram-tool-handlers.ts):
|
||||
* if any operation fails, nothing is written and every failure is reported,
|
||||
* so the model never builds on a half-applied edit.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
type DiagramOperation,
|
||||
} from "./diagram-operations.ts"
|
||||
import { type PageSelector, projectPage } from "./pages.ts"
|
||||
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
|
||||
|
||||
export type EditOutcome =
|
||||
| { ok: true; xml: string; applied: number; fixes: string[] }
|
||||
| { ok: false; errors: string[]; pageError: boolean }
|
||||
|
||||
/** Number of top-level elements in an XML fragment. */
|
||||
function countTopLevelElements(fragment: string): number {
|
||||
const doc = new DOMParser().parseFromString(
|
||||
`<wrapper>${fragment}</wrapper>`,
|
||||
"text/xml",
|
||||
)
|
||||
// On a syntax error the browser adds a <parsererror> element (Chrome
|
||||
// next to the cells, Firefox as the root); the syntax is checked later
|
||||
const root = doc.documentElement
|
||||
if (!root || root.tagName === "parsererror") return 1
|
||||
return Array.from(root.children).filter(
|
||||
(el) => el.tagName !== "parsererror",
|
||||
).length
|
||||
}
|
||||
|
||||
/** The target page as a one-page <mxfile>, or the whole document. */
|
||||
export function targetPageXml(xml: string, selector: PageSelector): string {
|
||||
const projection = projectPage(xml, selector)
|
||||
return projection.ok ? projection.xml : xml
|
||||
}
|
||||
|
||||
export function editDiagram(
|
||||
xml: string,
|
||||
operations: DiagramOperation[],
|
||||
selector: PageSelector,
|
||||
): EditOutcome {
|
||||
const errors: string[] = []
|
||||
const fixes: string[] = []
|
||||
const prepared: DiagramOperation[] = []
|
||||
|
||||
for (const op of operations) {
|
||||
if (op.operation === "delete" || !op.new_xml) {
|
||||
prepared.push(op)
|
||||
continue
|
||||
}
|
||||
// Checked before validation: several cells fail the strict parser
|
||||
// with a misleading "only one root" syntax error.
|
||||
if (countTopLevelElements(op.new_xml) > 1) {
|
||||
errors.push(
|
||||
`${op.operation} ${op.cell_id}: new_xml must contain exactly one cell; use one add operation per cell`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const check = validateAndFixXml(op.new_xml)
|
||||
if (!check.valid) {
|
||||
errors.push(
|
||||
`${op.operation} ${op.cell_id}: invalid new_xml: ${check.error}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (check.fixed) {
|
||||
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
|
||||
}
|
||||
prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml })
|
||||
}
|
||||
if (errors.length > 0) return { ok: false, errors, pageError: false }
|
||||
|
||||
const { result, errors: opErrors } = applyDiagramOperations(
|
||||
xml,
|
||||
prepared,
|
||||
selector,
|
||||
)
|
||||
// An empty cellId means the page itself could not be edited
|
||||
const pageLevel = opErrors.find((e) => e.cellId === "")
|
||||
if (pageLevel) {
|
||||
return { ok: false, errors: [pageLevel.message], pageError: true }
|
||||
}
|
||||
if (opErrors.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: opErrors.map((e) => `${e.type} ${e.cellId}: ${e.message}`),
|
||||
pageError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate only the target page, and reject only errors this edit
|
||||
// introduced: problems already in other pages or in a loaded file must
|
||||
// not block every edit.
|
||||
const after = validateMxCellStructure(targetPageXml(result, selector))
|
||||
if (after && !validateMxCellStructure(targetPageXml(xml, selector))) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [`the edit would make the page invalid: ${after}`],
|
||||
pageError: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, xml: result, applied: operations.length, fixes }
|
||||
}
|
||||
@@ -17,7 +17,14 @@
|
||||
* change: the set of pages, each page's name, and each page's cell tree
|
||||
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
||||
*/
|
||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
||||
import {
|
||||
findPageElement,
|
||||
isMxGraphModel,
|
||||
normalizeToMxfile,
|
||||
type PageSelector,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "./pages.ts"
|
||||
|
||||
export type EditGateResult =
|
||||
| { ok: true }
|
||||
@@ -100,3 +107,38 @@ export function checkEditGate(
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The model was shown only the selected page of liveXml (get_diagram with a
|
||||
* page selector, or a rejected edit's error). It has seen the whole document
|
||||
* if the other pages are as it last saw them, or if it saw nothing before
|
||||
* (it then has no old copy of them to edit from). Returns the new
|
||||
* lastSeenXml: liveXml, or lastSeenXml unchanged.
|
||||
*/
|
||||
export function markPageSeen(
|
||||
lastSeenXml: string,
|
||||
liveXml: string,
|
||||
selector: PageSelector,
|
||||
): string {
|
||||
const parse = (xml: string) => {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
return normalized ? parseMxfile(normalized) : null
|
||||
}
|
||||
// An empty record also follows load_diagram and page tools that wrote
|
||||
// over unseen changes, when the model may remember older pages: only
|
||||
// a one-page document is then fully seen
|
||||
if (!lastSeenXml) {
|
||||
const pages = parse(liveXml)?.querySelectorAll("diagram").length ?? 1
|
||||
return pages <= 1 ? liveXml : lastSeenXml
|
||||
}
|
||||
const otherPages = (xml: string) => {
|
||||
const doc = parse(xml)
|
||||
if (!doc) return null
|
||||
findPageElement(doc, selector)?.element.remove()
|
||||
return contentFingerprint(serializeMxfile(doc))
|
||||
}
|
||||
const before = otherPages(lastSeenXml)
|
||||
return before !== null && before === otherPages(liveXml)
|
||||
? liveXml
|
||||
: lastSeenXml
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* A queue for tool handlers: each call waits until the previous one ended.
|
||||
* A tool call the client cancelled while it waited (the MCP SDK aborts its
|
||||
* extra.signal, the handler's last argument) is skipped.
|
||||
*/
|
||||
export function createExclusive() {
|
||||
let tail: Promise<unknown> = Promise.resolve()
|
||||
return function exclusive<T extends (...args: any[]) => Promise<unknown>>(
|
||||
handler: T,
|
||||
): T {
|
||||
return ((...args: unknown[]) => {
|
||||
const extra = args.at(-1) as { signal?: AbortSignal } | undefined
|
||||
const run = tail.then(() =>
|
||||
extra?.signal?.aborted
|
||||
? {
|
||||
content: [{ type: "text", text: "Cancelled." }],
|
||||
isError: true,
|
||||
}
|
||||
: handler(...args),
|
||||
)
|
||||
tail = run.catch(() => {})
|
||||
return run
|
||||
}) as T
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Stores {xml, svg} entries in a circular buffer
|
||||
*/
|
||||
|
||||
import { log } from "./logger.js"
|
||||
import { log } from "./logger.ts"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
@@ -23,9 +23,12 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
historyStore.set(sessionId, history)
|
||||
}
|
||||
|
||||
// Dedupe: skip if same as last entry
|
||||
// Dedupe: skip if same as last entry (any other change, also of page
|
||||
// settings only, is a new version; keepInHistory leaves out draw.io's
|
||||
// copy of a server write)
|
||||
const last = history[history.length - 1]
|
||||
if (last?.xml === xml) {
|
||||
if (last && last.xml === xml) {
|
||||
if (svg && !last.svg) last.svg = svg
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
@@ -56,11 +59,19 @@ export function clearHistory(sessionId: string): void {
|
||||
historyStore.delete(sessionId)
|
||||
}
|
||||
|
||||
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
|
||||
/**
|
||||
* Give the last entry the image the browser took of shownXml, the diagram
|
||||
* it just loaded, when that entry is this diagram
|
||||
*/
|
||||
export function updateLastHistorySvg(
|
||||
sessionId: string,
|
||||
svg: string,
|
||||
shownXml: string,
|
||||
): boolean {
|
||||
const history = historyStore.get(sessionId)
|
||||
if (!history || history.length === 0) return false
|
||||
const last = history[history.length - 1]
|
||||
if (!last.svg) {
|
||||
if (!last.svg && last.xml === shownXml) {
|
||||
last.svg = svg
|
||||
return true
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+541
-285
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,15 @@
|
||||
* diagram's text content. The rest of the server assumes plain XML inside
|
||||
* every <diagram>, so loading decompresses all pages up front.
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import { inflateRaw } from "pako"
|
||||
import {
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "./pages.js"
|
||||
} from "./pages.ts"
|
||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; xml: string }
|
||||
@@ -28,9 +27,11 @@ export type LoadResult =
|
||||
*/
|
||||
export function decompressPageContent(compressed: string): string | null {
|
||||
try {
|
||||
const inflated = inflateRawSync(
|
||||
Buffer.from(compressed.trim(), "base64"),
|
||||
).toString("utf-8")
|
||||
// atob and pako work in Node and in the browser
|
||||
const bytes = Uint8Array.from(atob(compressed.trim()), (c) =>
|
||||
c.charCodeAt(0),
|
||||
)
|
||||
const inflated = inflateRaw(bytes, { to: "string" })
|
||||
try {
|
||||
return decodeURIComponent(inflated)
|
||||
} catch {
|
||||
@@ -49,14 +50,16 @@ export function decompressPageContent(compressed: string): string | null {
|
||||
* any compressed pages.
|
||||
*/
|
||||
export function parseDrawioFileContent(content: string): LoadResult {
|
||||
const trimmed = content.trim()
|
||||
let trimmed = content.trim()
|
||||
if (!trimmed) return { ok: false, error: "File is empty." }
|
||||
|
||||
if (isMxGraphModel(trimmed)) {
|
||||
const normalized = normalizeToMxfile(trimmed)
|
||||
return normalized
|
||||
? { ok: true, xml: normalized }
|
||||
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
||||
}
|
||||
// Parsed below like any <mxfile>, so a broken model is an error
|
||||
trimmed = normalized
|
||||
}
|
||||
if (!isMxFile(trimmed)) {
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,8 @@ export const log = {
|
||||
console.error(`[MCP-DrawIO] [ERROR] ${msg}`, ...args)
|
||||
},
|
||||
debug: (msg: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG === "true") {
|
||||
// process is missing when the web app runs this code in the browser
|
||||
if (typeof process !== "undefined" && process.env.DEBUG === "true") {
|
||||
console.error(`[MCP-DrawIO] [DEBUG] ${msg}`, ...args)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A whole new diagram written by the model, for the create_new_diagram tool
|
||||
* and the web app's display_diagram tool.
|
||||
*/
|
||||
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
|
||||
import { readAttributes } from "./xml-attributes.ts"
|
||||
import { validateAndFixXml } from "./xml-validation.ts"
|
||||
|
||||
export type NewDiagram =
|
||||
| { ok: true; xml: string; fixes: string[] }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Bare cells get the root cells "0" and "1". A shape or edge with one of
|
||||
* these ids would be renamed as a duplicate, breaking its edges. Its id may
|
||||
* be on a <UserObject> or <object> wrapper. Returns the error for the model,
|
||||
* or null.
|
||||
*/
|
||||
export function reservedIdError(input: string): string | null {
|
||||
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
|
||||
// Each opening tag with its attributes; quoted values are read as a
|
||||
// whole, so text such as label="id='1'" is not an attribute
|
||||
const tags = input.matchAll(
|
||||
/<(mxCell|UserObject|object)\b((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/g,
|
||||
)
|
||||
for (const [, tag, attrText] of tags) {
|
||||
const attrs = new Map(
|
||||
readAttributes(attrText).map((a) => [a.name, a.value]),
|
||||
)
|
||||
const id = attrs.get("id")
|
||||
if (id !== "0" && id !== "1") continue
|
||||
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
|
||||
if (
|
||||
tag !== "mxCell" ||
|
||||
attrs.get("vertex") === "1" ||
|
||||
attrs.get("edge") === "1"
|
||||
) {
|
||||
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare cells get the wrapper and root cells first, since the strict parser
|
||||
* rejects several top-level elements. Then the XML is validated and
|
||||
* auto-fixed while it is still a bare model, where duplicate ids are
|
||||
* renamed, and finally turned into an <mxfile>.
|
||||
*/
|
||||
export function prepareNewDiagram(
|
||||
input: string,
|
||||
page: { pageId?: string; pageName?: string } = {},
|
||||
): NewDiagram {
|
||||
const reserved = reservedIdError(input)
|
||||
if (reserved) return { ok: false, error: reserved }
|
||||
let xml = wrapCellsInModel(input)
|
||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||
if (fixed) xml = fixed
|
||||
if (!valid) {
|
||||
return { ok: false, error: `XML validation failed - ${error}` }
|
||||
}
|
||||
const normalized = normalizeToMxfile(xml, page)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
||||
}
|
||||
}
|
||||
return { ok: true, xml: normalized, fixes }
|
||||
}
|
||||
@@ -17,8 +17,8 @@
|
||||
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import { readAttributes } from "./xml-attributes.ts"
|
||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||
|
||||
export interface PageInfo {
|
||||
id: string
|
||||
@@ -53,6 +53,15 @@ export function generatePageId(): string {
|
||||
return `${a}-${b}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Any cell besides the root cells "0" and "1", or a page in draw.io's
|
||||
* compressed format (text instead of a model), which is not checked further
|
||||
*/
|
||||
export const hasCells = (xml: string) =>
|
||||
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(
|
||||
xml,
|
||||
)
|
||||
|
||||
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
||||
export function isMxFile(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
||||
@@ -81,6 +90,53 @@ function stripXmlDeclaration(xml: string): string {
|
||||
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
||||
}
|
||||
|
||||
const ROOT_CELLS = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
||||
|
||||
/** A one-page document with only the root cells */
|
||||
export const BLANK_MXFILE = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root>${ROOT_CELLS}</root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
/**
|
||||
* Turn a list of bare cells (optionally inside <root>) into a one-page
|
||||
* <mxGraphModel>, adding the "0" and "1" root cells. The model then only
|
||||
* writes its own cells, as in the web app (wrapWithMxFile in lib/utils.ts).
|
||||
* Root cells the model wrote anyway are replaced, and comments or text
|
||||
* before the first cell and trailing closing tags some providers append
|
||||
* are dropped. <mxfile>, <mxGraphModel> and anything else are returned
|
||||
* unchanged.
|
||||
*/
|
||||
export function wrapCellsInModel(xml: string): string {
|
||||
let content = stripXmlDeclaration(xml.trim())
|
||||
const start = content.search(/<(mxCell|UserObject|object|root)[\s/>]/)
|
||||
if (start === -1) return xml
|
||||
// Only comments and plain text may come before the first cell
|
||||
if (!/^(?:<!--[\s\S]*?-->|[^<])*$/.test(content.slice(0, start))) {
|
||||
return xml
|
||||
}
|
||||
|
||||
content = content
|
||||
.slice(start)
|
||||
.replace(/<\/?root>/g, "")
|
||||
.trim()
|
||||
// End of the last cell, counting wrapped cells (</UserObject>, </object>)
|
||||
let end = -1
|
||||
for (const close of ["/>", "</mxCell>", "</UserObject>", "</object>"]) {
|
||||
const at = content.lastIndexOf(close)
|
||||
if (at !== -1) end = Math.max(end, at + close.length)
|
||||
}
|
||||
if (end !== -1 && /^(\s*<\/[^>]+>)*\s*$/.test(content.slice(end))) {
|
||||
content = content.slice(0, end)
|
||||
}
|
||||
// The root cells come with the wrapper (a label holding id='1' is not
|
||||
// an id)
|
||||
content = content
|
||||
.replace(/<mxCell\b[^>]*?(?:\/>|>\s*<\/mxCell>)/g, (cell) => {
|
||||
const id = readAttributes(cell).find((a) => a.name === "id")?.value
|
||||
return id === "0" || id === "1" ? "" : cell
|
||||
})
|
||||
.trim()
|
||||
return `<mxGraphModel><root>${ROOT_CELLS}${content}</root></mxGraphModel>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
||||
* If the input is already an mxfile, returns it unchanged.
|
||||
@@ -255,7 +311,7 @@ export function addPageToDoc(
|
||||
}
|
||||
inner = trimmed
|
||||
} else {
|
||||
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
||||
inner = `<mxGraphModel><root>${ROOT_CELLS}</root></mxGraphModel>`
|
||||
}
|
||||
|
||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||
@@ -302,14 +358,15 @@ export function deletePageFromDoc(
|
||||
doc: Document,
|
||||
selector: PageSelector,
|
||||
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
||||
const pages = listPagesFromDoc(doc)
|
||||
if (pages.length <= 1) {
|
||||
return { ok: false, reason: "Cannot delete the only remaining page" }
|
||||
}
|
||||
// Match first, so a wrong selector reports "not found" even on a
|
||||
// one-page document
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) {
|
||||
return { ok: false, reason: "Page not found" }
|
||||
}
|
||||
if (listPagesFromDoc(doc).length <= 1) {
|
||||
return { ok: false, reason: "Cannot delete the only remaining page" }
|
||||
}
|
||||
const id = found.element.getAttribute("id") || ""
|
||||
const index = found.index
|
||||
found.element.parentNode?.removeChild(found.element)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Auto-save of each session's latest diagram as a plain .drawio file, so a
|
||||
* diagram survives the MCP process (hosts start a new one when a
|
||||
* conversation is resumed). Like the web app's IndexedDB sessions
|
||||
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
||||
* 50 kept. History is not saved.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
||||
|
||||
// The blank page the browser shows before any drawing (page names count:
|
||||
// empty pages the user named or added are kept)
|
||||
const isBlank = (xml: string) =>
|
||||
!hasCells(xml) &&
|
||||
contentFingerprint(xml) === contentFingerprint(BLANK_MXFILE)
|
||||
|
||||
const DELAY_MS = 1000
|
||||
const MAX_FILES = 50
|
||||
|
||||
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
||||
export function expandHome(p: string): string {
|
||||
if (p === "~") return homedir()
|
||||
if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1)
|
||||
return p
|
||||
}
|
||||
|
||||
/** DRAWIO_DATA_DIR, default ~/.next-ai-drawio; "off" disables saving. */
|
||||
export function defaultDataDir(): string | null {
|
||||
const dir = process.env.DRAWIO_DATA_DIR
|
||||
if (dir === "off") return null
|
||||
return dir ? expandHome(dir) : join(homedir(), ".next-ai-drawio")
|
||||
}
|
||||
|
||||
/** The file surely does not exist (not merely out of reach) */
|
||||
function isGone(path: string): boolean {
|
||||
try {
|
||||
statSync(path)
|
||||
return false
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
}
|
||||
}
|
||||
|
||||
export class Autosaver {
|
||||
private pending = new Map<
|
||||
string,
|
||||
{ xml: string; timer: ReturnType<typeof setTimeout> }
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private dir: string | null,
|
||||
private delayMs = DELAY_MS,
|
||||
private maxFiles = MAX_FILES,
|
||||
) {}
|
||||
|
||||
/** Path of a session's file, or null when saving is off. */
|
||||
pathFor(sessionId: string): string | null {
|
||||
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
||||
}
|
||||
|
||||
// Saved files that could not be read back: never written over, since
|
||||
// the session then shows something else than what they hold. Cleared
|
||||
// once the file is read, or is surely gone (a folder without permission
|
||||
// also makes a file look missing).
|
||||
private unreadable = new Set<string>()
|
||||
|
||||
/** The session's saved diagram, or null. */
|
||||
load(sessionId: string): string | null {
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!path) return null
|
||||
try {
|
||||
const xml = readFileSync(path, "utf-8")
|
||||
this.unreadable.delete(path)
|
||||
return xml
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.unreadable.delete(path)
|
||||
return null
|
||||
}
|
||||
log.warn(`Could not read the saved diagram ${path}: ${error}`)
|
||||
this.unreadable.add(path)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(sessionId: string, xml: string): void {
|
||||
if (!this.dir) return
|
||||
const previous = this.pending.get(sessionId)
|
||||
if (previous) clearTimeout(previous.timer)
|
||||
const timer = setTimeout(() => this.write(sessionId), this.delayMs)
|
||||
timer.unref?.()
|
||||
this.pending.set(sessionId, { xml, timer })
|
||||
}
|
||||
|
||||
/** Write every pending save now (on shutdown). */
|
||||
flush(): void {
|
||||
for (const [sessionId, { timer }] of this.pending) {
|
||||
clearTimeout(timer)
|
||||
this.write(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private write(sessionId: string): void {
|
||||
const entry = this.pending.get(sessionId)
|
||||
this.pending.delete(sessionId)
|
||||
const path = this.pathFor(sessionId)
|
||||
if (!entry || !this.dir || !path) return
|
||||
if (this.unreadable.has(path)) {
|
||||
// Deleted meanwhile: nothing left to protect
|
||||
if (isGone(path)) {
|
||||
this.unreadable.delete(path)
|
||||
} else {
|
||||
log.warn(
|
||||
`Not saving ${path}: it could not be read, so it may hold work this session does not show`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const isNew = !existsSync(path)
|
||||
// A blank page the browser shows before any drawing: nothing to keep
|
||||
if (isNew && isBlank(entry.xml)) return
|
||||
mkdirSync(this.dir, { recursive: true })
|
||||
// Write to a temporary file first so a crash never leaves half a file
|
||||
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
||||
renameSync(`${path}.tmp`, path)
|
||||
if (isNew) this.removeOldest()
|
||||
} catch (error) {
|
||||
log.warn(`Auto-save failed for ${path}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldest(): void {
|
||||
if (!this.dir) return
|
||||
const dir = this.dir
|
||||
// Only our own session files (mcp-<time in base 36>-<random>, made
|
||||
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
||||
// the user's diagrams
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
|
||||
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
for (const { f } of files.slice(this.maxFiles)) {
|
||||
unlinkSync(join(dir, f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Next AI Draw.io</title>
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<div class="brand">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
||||
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
||||
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="title">Next AI Draw.io</span>
|
||||
{{SESSION_BADGE}}
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="history-btn" title="History" {{DISABLED}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
History
|
||||
</button>
|
||||
<button id="save-btn" {{DISABLED}}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=auto" title="draw.io editor"></iframe>
|
||||
</div>
|
||||
<div id="notice" role="status"></div>
|
||||
<div id="history-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h2>History</h2></div>
|
||||
<div class="modal-body">
|
||||
<div id="history-grid" class="history-grid"></div>
|
||||
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="save-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h2>Download Diagram</h2></div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Format</label>
|
||||
<select id="save-format">
|
||||
<option value="drawio">Draw.io (.drawio)</option>
|
||||
<option value="png">PNG Image (.png)</option>
|
||||
<option value="svg">SVG Vector (.svg)</option>
|
||||
<option value="xmlsvg">Editable SVG (.drawio.svg)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Filename</label>
|
||||
<div class="filename-group">
|
||||
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename" autocomplete="off">
|
||||
<span class="ext" id="save-ext">.drawio</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const sessionId = {{SESSION_JSON}};
|
||||
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
|
||||
</script>
|
||||
<script>
|
||||
{{SCRIPT}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||
#header {
|
||||
padding: 0 20px; height: 52px;
|
||||
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
||||
border-bottom: 1px solid #e8ecf0;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
position: relative; z-index: 10;
|
||||
}
|
||||
#header .brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
#header .logo {
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
background: #18181b;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
||||
#header .title {
|
||||
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
#header .session {
|
||||
font-size: 11px; color: #8b95a5; font-weight: 400;
|
||||
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
||||
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
#header .right { display: flex; align-items: center; gap: 12px; }
|
||||
#save-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||
color: white; border: none; cursor: pointer;
|
||||
font-weight: 500; font-family: inherit;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#save-btn svg { width: 14px; height: 14px; }
|
||||
#save-btn:hover {
|
||||
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
#save-btn:active { transform: translateY(0); }
|
||||
#save-btn:disabled, #history-btn:disabled {
|
||||
background: #e5e7eb; color: #9ca3af;
|
||||
cursor: not-allowed; transform: none; box-shadow: none;
|
||||
}
|
||||
#history-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||
cursor: pointer; font-weight: 500; font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#history-btn svg { width: 14px; height: 14px; }
|
||||
#history-btn:hover {
|
||||
background: #e4e4e7; border-color: #d4d4d8;
|
||||
}
|
||||
#drawio { flex: 1; border: none; }
|
||||
#history-modal, #save-modal {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
||||
z-index: 2000; align-items: center; justify-content: center;
|
||||
}
|
||||
#history-modal.open, #save-modal.open { display: flex; }
|
||||
.modal-content {
|
||||
background: white; border-radius: 16px;
|
||||
width: 90%; max-width: 480px; max-height: 70vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
animation: modalIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
.modal-footer {
|
||||
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
||||
display: flex; gap: 10px; justify-content: flex-end;
|
||||
}
|
||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.history-item {
|
||||
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
||||
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
||||
background: #fafafa;
|
||||
}
|
||||
.history-item:hover { border-color: #a1a1aa; background: white; }
|
||||
.history-item.selected {
|
||||
border-color: #18181b; background: white;
|
||||
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
||||
}
|
||||
.history-item .thumb {
|
||||
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 6px; overflow: hidden;
|
||||
}
|
||||
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
||||
.btn {
|
||||
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
||||
cursor: pointer; border: none; font-weight: 500;
|
||||
font-family: inherit; transition: all 0.15s ease;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||
color: white;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: #e4e4e7; color: #a1a1aa;
|
||||
cursor: not-allowed; transform: none; box-shadow: none;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||
}
|
||||
.btn-secondary:hover { background: #e4e4e7; }
|
||||
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 500;
|
||||
margin-bottom: 8px; color: #3f3f46;
|
||||
}
|
||||
.form-group select, .form-group input {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
||||
border-radius: 8px; font-size: 14px; outline: none;
|
||||
font-family: inherit; background: white;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.form-group select:focus, .form-group input:focus {
|
||||
border-color: #18181b;
|
||||
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
||||
}
|
||||
.filename-group { display: flex; }
|
||||
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
||||
#notice {
|
||||
display: none; position: fixed; left: 50%; bottom: 24px;
|
||||
transform: translateX(-50%); z-index: 3000; max-width: 480px;
|
||||
padding: 10px 16px; border-radius: 8px; font-size: 13px;
|
||||
background: #18181b; color: white;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
#notice.open { display: block; }
|
||||
.filename-group .ext {
|
||||
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
||||
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
/* Dark mode follows the system, like draw.io with dark=auto */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#header { background: #1c1c20; border-bottom-color: #2e2e34; box-shadow: none; }
|
||||
#header .title { color: #f4f4f5; }
|
||||
#header .session { background: #2a2a30; color: #a1a1aa; }
|
||||
#history-btn, .btn-secondary { background: #2a2a30; color: #e4e4e7; border-color: #3f3f46; }
|
||||
#history-btn:hover, .btn-secondary:hover { background: #3f3f46; border-color: #52525b; }
|
||||
#save-btn, .btn-primary { background: #f4f4f5; color: #18181b; }
|
||||
#save-btn:hover, .btn-primary:hover { background: #e4e4e7; }
|
||||
#save-btn:disabled, #history-btn:disabled, .btn-primary:disabled { background: #2a2a30; color: #71717a; }
|
||||
.modal-content { background: #1c1c20; color: #e4e4e7; }
|
||||
.modal-header, .modal-footer { border-color: #2e2e34; }
|
||||
.modal-header h2 { color: #f4f4f5; }
|
||||
.history-item { background: #27272a; border-color: #3f3f46; }
|
||||
.history-item:hover { background: #2e2e34; border-color: #71717a; }
|
||||
.history-item.selected { background: #2e2e34; border-color: #f4f4f5; box-shadow: 0 0 0 3px rgba(244,244,245,0.15); }
|
||||
.history-item .label, .empty { color: #a1a1aa; }
|
||||
.form-group label { color: #d4d4d8; }
|
||||
.form-group select, .form-group input { background: #27272a; color: #f4f4f5; border-color: #3f3f46; }
|
||||
.form-group select:focus, .form-group input:focus { border-color: #a1a1aa; box-shadow: 0 0 0 3px rgba(244,244,245,0.1); }
|
||||
.filename-group .ext { background: #2a2a30; border-color: #3f3f46; color: #a1a1aa; }
|
||||
#notice { background: #f4f4f5; color: #18181b; }
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
const iframe = document.getElementById('drawio');
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
// The server state this tab is in step with (see stateId in http-server.ts);
|
||||
// null until the first poll
|
||||
let stateId = null;
|
||||
// The newest diagram on the canvas, saved to the server or not: lastXml is
|
||||
// the last one the server has
|
||||
let latestXml = null;
|
||||
let pushFailing = false; // the last push could not reach the server
|
||||
const pushesInFlight = []; // XML of pushes not answered yet
|
||||
// After recovery replaced the canvas, until draw.io reports the load: an
|
||||
// autosave still on its way belongs to the canvas being replaced
|
||||
let awaitingLoad = false;
|
||||
let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped
|
||||
let pendingSvgExport = null;
|
||||
let pendingSvgBase = 0; // version the pending autosave was based on
|
||||
let pendingSvgStateId = null; // and the state it belonged to
|
||||
// The latest thumbnail export of a loaded server write: its number (echoed
|
||||
// by draw.io), the state and version it showed, and the XML loaded
|
||||
let thumbExportSeq = 0, thumbExport = null;
|
||||
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
|
||||
let mcpExportSeq = 0; // number of the latest MCP export
|
||||
let mcpExportId = null; // the server's id for it, sent back with the result
|
||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||
let forceReload = false; // reload the server state on the next poll even if the version is unchanged
|
||||
let noticeTimer = null;
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== DRAWIO_ORIGIN) return;
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.event === 'init') {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if (msg.event === 'load') {
|
||||
awaitingLoad = false;
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) {
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// An edit of the canvas that recovery is replacing: kept in
|
||||
// History, never over the recovered diagram
|
||||
if (awaitingLoad) { pushState(msg.xml, '', currentVersion, 'recover'); return; }
|
||||
// Also an edit undone back to what the server has
|
||||
latestXml = msg.xml;
|
||||
if (msg.xml === lastXml) return;
|
||||
// Request SVG export, then push state with SVG. Remember the
|
||||
// version and state this edit is based on, so the server can
|
||||
// reject it if the AI wrote a newer version that is not loaded
|
||||
// yet, or if it lost that state.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
pendingSvgStateId = stateId;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data. A late
|
||||
// reply to an earlier request was taken at another version.
|
||||
if (pendingSyncExport && msg.xml && msg.message?.syncExport === syncExportSeq) {
|
||||
pendingSyncExport = false;
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
// older XML must not overwrite it.
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId);
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
// the request with mcpExport and draw.io echoes the request
|
||||
// back in msg.message, which tells it apart from autosave and
|
||||
// preview SVG exports.
|
||||
if (msg.message && msg.message.mcpExport) {
|
||||
// A late reply to an export that already timed out
|
||||
if (msg.message.mcpExport !== mcpExportSeq) return;
|
||||
const d = msg.data;
|
||||
const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png');
|
||||
const isSvg = (pendingMcpExport === 'svg' || pendingMcpExport === 'xmlsvg') && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
||||
if (isPng || isSvg) {
|
||||
// Keep pendingMcpExport set until the server has the
|
||||
// result: a poll answered before that still sees the
|
||||
// request and would start the same export again.
|
||||
const seq = msg.message.mcpExport;
|
||||
fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
|
||||
}).catch(() => {}).finally(() => {
|
||||
// The timeout already ended this export
|
||||
if (seq !== mcpExportSeq) return;
|
||||
pendingMcpExport = null;
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle file download export (PNG/SVG only, drawio uses
|
||||
// lastXml directly). Tagged with dlExport like mcpExport,
|
||||
// so an autosave SVG export can never be saved instead.
|
||||
if (msg.message && msg.message.dlExport) {
|
||||
if (!pendingDownload) return;
|
||||
const dl = pendingDownload;
|
||||
pendingDownload = null;
|
||||
let dataUrl = msg.data;
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
||||
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl; a.download = dl.filename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
saveModal.classList.remove('open');
|
||||
saveConfirmBtn.disabled = false;
|
||||
saveConfirmBtn.textContent = 'Save';
|
||||
return;
|
||||
}
|
||||
// Handle SVG export
|
||||
let svg = msg.data;
|
||||
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||
if (msg.message && msg.message.thumbExport) {
|
||||
// Only for the latest load, and only if the canvas still
|
||||
// shows it: the export pictures the canvas as it is now
|
||||
const t = thumbExport;
|
||||
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
|
||||
thumbExport = null;
|
||||
fetch('/api/history-svg', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
|
||||
}).catch(() => {});
|
||||
} else if (pendingSvgExport) {
|
||||
const xml = pendingSvgExport;
|
||||
pendingSvgExport = null;
|
||||
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function loadDiagram(xml, capturePreview = false) {
|
||||
if (!isReady) { pendingXml = xml; return; }
|
||||
lastXml = xml;
|
||||
latestXml = xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
||||
if (capturePreview) {
|
||||
// A server write: currentVersion is its version
|
||||
const t = { n: ++thumbExportSeq, stateId, version: currentVersion, xml };
|
||||
thumbExport = t;
|
||||
setTimeout(() => {
|
||||
if (thumbExport !== t) return; // a newer load takes its own
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg', thumbExport: t.n }), '*');
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the user's real document after a page-targeted projection
|
||||
// export by reloading the server state. The server also has any
|
||||
// autosave that was still in flight when the projection started,
|
||||
// which a copy taken at that moment would miss. A flag is used
|
||||
// because a push finishing meanwhile may update currentVersion.
|
||||
// projectionExportActive stays set until the poll loads the document:
|
||||
// an edit on the projection before that must not be pushed.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
forceReload = true;
|
||||
poll();
|
||||
}
|
||||
|
||||
function showNotice(text) {
|
||||
const el = document.getElementById('notice');
|
||||
el.textContent = text;
|
||||
el.classList.add('open');
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||
}
|
||||
|
||||
// source is 'sync' for replies to a server sync request, 'recover' for the
|
||||
// tab's copy after the server recovered the session, else 'edit'. sid is the
|
||||
// server state the push is based on.
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
|
||||
if (!sessionId) return;
|
||||
pushesInFlight.push(xml);
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
||||
});
|
||||
pushFailing = false;
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
// An answer about a state this tab has left since, or one that
|
||||
// comes after a newer version was loaded or saved
|
||||
if (sid !== stateId || d.version < currentVersion) return;
|
||||
currentVersion = d.version;
|
||||
lastXml = xml;
|
||||
// The canvas changed while this edit was on its way, to
|
||||
// something no pending autosave will send (an undo back to the
|
||||
// previous version): send it now. A sync reply is draw.io's
|
||||
// export of the canvas, in another format than its autosave.
|
||||
if (latestXml && latestXml !== xml && pendingSvgExport !== latestXml && source === 'edit') {
|
||||
pushState(latestXml);
|
||||
}
|
||||
}
|
||||
// Over the server's size limit: the image is most of it, so try once
|
||||
// without it
|
||||
else if (r.status === 413) {
|
||||
if (svg) pushState(xml, '', baseVersion, source, sid);
|
||||
else showNotice('This diagram is too large to save to the MCP server (over 10 MB). Use Download to keep it.');
|
||||
}
|
||||
// 409: the AI wrote a newer version, or the server lost the state
|
||||
// this push was based on; the next poll sorts it out
|
||||
else if (r.status === 409) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (d.savedToHistory) {
|
||||
showNotice(source === 'recover'
|
||||
? 'The diagram was restored from its saved file. What this tab showed before is in History.'
|
||||
: 'The AI changed the diagram while you were editing. Your last change was saved in History.');
|
||||
}
|
||||
poll();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Push failed:', e);
|
||||
if (!pushFailing) {
|
||||
pushFailing = true;
|
||||
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
|
||||
}
|
||||
} finally {
|
||||
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// The server made a new state for this session: it expired, or the MCP
|
||||
// process restarted. Decide whose diagram wins.
|
||||
function recoverState(s) {
|
||||
stateId = s.stateId;
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive;
|
||||
projectionExportActive = false;
|
||||
forceReload = false;
|
||||
pendingMcpExport = null;
|
||||
pendingSyncExport = false;
|
||||
const mine = latestXml;
|
||||
currentVersion = s.version;
|
||||
if (s.blank || s.xml === lastXml) {
|
||||
// The server knows nothing, or exactly what this tab last saved:
|
||||
// the canvas can only be newer, so it wins (edits made while the
|
||||
// server was down are saved now)
|
||||
if (projectionShown && mine) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
|
||||
expectLoad();
|
||||
}
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version);
|
||||
} else {
|
||||
// The server has a diagram this tab never showed (an AI write it
|
||||
// missed, a saved file): show that, and keep this tab's copy in
|
||||
// History unless it is the same
|
||||
loadDiagram(s.xml, true);
|
||||
expectLoad();
|
||||
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
|
||||
}
|
||||
}
|
||||
|
||||
// Until draw.io reports the load (its messages come in order), an autosave
|
||||
// is from the canvas being replaced; in case no report comes, not for long
|
||||
function expectLoad() {
|
||||
awaitingLoad = true;
|
||||
setTimeout(() => { awaitingLoad = false; }, 5000);
|
||||
}
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
let pendingSyncStateId = null; // and the state it belonged to
|
||||
let syncExportSeq = 0; // number of the latest sync export
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const seq = ++pollSeq;
|
||||
try {
|
||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
// An older answer than one already handled (the interval, the 409
|
||||
// handler and the projection restore each poll): it could name a
|
||||
// state that is gone
|
||||
if (seq < lastHandledPoll) return;
|
||||
lastHandledPoll = seq;
|
||||
if (stateId === null) stateId = s.stateId;
|
||||
else if (s.stateId && s.stateId !== stateId) recoverState(s);
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, only the restore
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
// projection; currentVersion stays unadvanced until then, so the
|
||||
// bump is applied with the real document.
|
||||
// The tab's own push still on its way is not loaded back: the
|
||||
// canvas may have moved on since (an undo), and its answer follows
|
||||
const ownPush = pushesInFlight.includes(s.xml);
|
||||
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
|
||||
forceReload = false;
|
||||
projectionExportActive = false;
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
// Handle sync request - server needs fresh state. After the load
|
||||
// above, so draw.io exports what it just loaded; never while a
|
||||
// one-page projection is on screen, which would be sent as the
|
||||
// whole document. Reset after a while in case draw.io never
|
||||
// answers, so later syncs still run.
|
||||
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
pendingSyncStateId = stateId;
|
||||
// draw.io echoes the request in msg.message, so the reply can
|
||||
// be matched to this request
|
||||
const seq = ++syncExportSeq;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml', syncExport: seq }), '*');
|
||||
setTimeout(() => { if (seq === syncExportSeq) pendingSyncExport = false; }, 5000);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg).
|
||||
//
|
||||
// Plain export: capture whatever tab is currently displayed.
|
||||
//
|
||||
// Page-targeted export: the server sends a single-page <mxfile>
|
||||
// projection in s.exportXml. We load it into the iframe, let
|
||||
// draw.io render it, export, then reload the user's real
|
||||
// document — all browser-side. The canonical session state is
|
||||
// never mutated, so there is no server-side restore race and no
|
||||
// dependence on poll timing. autosave is suppressed while the
|
||||
// projection is showing (see projectionExportActive guard).
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const seq = ++mcpExportSeq;
|
||||
mcpExportId = s.exportId;
|
||||
const extra = s.exportOptions || {};
|
||||
const fireExport = () => {
|
||||
// mcpExport carries this export's number and is echoed
|
||||
// back in msg.message (see the handler). PNG: width
|
||||
// caps the size, pageId picks a page; without one
|
||||
// draw.io would use the first page.
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq }
|
||||
: { action: 'export', format: pendingMcpExport, mcpExport: seq };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
projectionExportActive = true;
|
||||
// Load the projection without touching lastXml/server state.
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
||||
// Let draw.io render the loaded page before exporting
|
||||
// (same proven settle delay as the AI-preview path).
|
||||
setTimeout(fireExport, 600);
|
||||
} else {
|
||||
fireExport();
|
||||
}
|
||||
// Timeout: reset if draw.io never responds, and restore the
|
||||
// real document if a projection was left showing. Only for
|
||||
// this export: a later one may be running by then.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
pendingMcpExport = null;
|
||||
restoreFromProjection();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||
|
||||
// Save modal
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const saveModal = document.getElementById('save-modal');
|
||||
const saveFormat = document.getElementById('save-format');
|
||||
const saveFilename = document.getElementById('save-filename');
|
||||
const saveExt = document.getElementById('save-ext');
|
||||
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
||||
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
||||
let pendingDownload = null;
|
||||
|
||||
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg', xmlsvg: '.drawio.svg' };
|
||||
|
||||
saveBtn.onclick = () => {
|
||||
if (!sessionId || !isReady) return;
|
||||
// Local date as YYYY-MM-DD, like the web app's default name
|
||||
saveFilename.value = 'diagram-' + new Date().toLocaleDateString('sv-SE');
|
||||
saveModal.classList.add('open');
|
||||
saveFilename.focus();
|
||||
saveFilename.select();
|
||||
};
|
||||
|
||||
saveFilename.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' && !e.isComposing && !saveConfirmBtn.disabled) saveConfirmBtn.onclick();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (saveModal.classList.contains('open')) saveCancelBtn.onclick();
|
||||
if (historyModal.classList.contains('open')) cancelBtn.onclick();
|
||||
});
|
||||
|
||||
saveFormat.onchange = () => {
|
||||
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
||||
};
|
||||
|
||||
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
||||
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
||||
|
||||
saveConfirmBtn.onclick = () => {
|
||||
const format = saveFormat.value;
|
||||
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
||||
saveConfirmBtn.disabled = true;
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
if (format === 'drawio') {
|
||||
// Use the XML directly instead of requesting export (avoids race with SVG exports).
|
||||
// session.xml is canonically <mxfile> after the multi-page refactor,
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
// The canvas as it is, also edits not saved to the server yet
|
||||
let xmlData = latestXml || lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
}
|
||||
const blob = new Blob([xmlData], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
saveModal.classList.remove('open');
|
||||
saveConfirmBtn.disabled = false;
|
||||
saveConfirmBtn.textContent = 'Save';
|
||||
} else if (format === 'png') {
|
||||
pendingDownload = { format: 'png', filename };
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2, currentPage: true, dlExport: true }), '*');
|
||||
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||
} else {
|
||||
// svg, or xmlsvg: an SVG with the diagram embedded, which draw.io can open again
|
||||
pendingDownload = { format, filename };
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format, dlExport: true }), '*');
|
||||
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// History UI
|
||||
const historyBtn = document.getElementById('history-btn');
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const historyGrid = document.getElementById('history-grid');
|
||||
const historyEmpty = document.getElementById('history-empty');
|
||||
const restoreBtn = document.getElementById('restore-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
let historyData = [], selectedId = null;
|
||||
|
||||
historyBtn.onclick = async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
historyData = d.entries || [];
|
||||
renderHistory();
|
||||
}
|
||||
} catch {}
|
||||
historyModal.classList.add('open');
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedId = null; restoreBtn.disabled = true; };
|
||||
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
||||
|
||||
function renderHistory() {
|
||||
if (historyData.length === 0) {
|
||||
historyGrid.style.display = 'none';
|
||||
historyEmpty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
historyGrid.style.display = 'grid';
|
||||
historyEmpty.style.display = 'none';
|
||||
// Built element by element: a stored image is never read as HTML, and
|
||||
// only an SVG data URL is shown as one
|
||||
historyGrid.replaceChildren(...historyData.map((e) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.dataset.id = String(e.id);
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'thumb';
|
||||
if (typeof e.svg === 'string' && e.svg.startsWith('data:image/svg+xml;base64,')) {
|
||||
const img = document.createElement('img');
|
||||
img.src = e.svg;
|
||||
thumb.appendChild(img);
|
||||
} else {
|
||||
thumb.textContent = '#' + e.index;
|
||||
}
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = '#' + e.index;
|
||||
item.append(thumb, label);
|
||||
return item;
|
||||
}));
|
||||
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||
item.onclick = () => {
|
||||
const id = parseInt(item.dataset.id);
|
||||
if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; }
|
||||
else { selectedId = id; restoreBtn.disabled = false; }
|
||||
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
restoreBtn.onclick = async () => {
|
||||
if (selectedId === null) return;
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restoring...';
|
||||
try {
|
||||
const r = await fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, id: selectedId })
|
||||
});
|
||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||
else { showNotice('Restore failed. Please try again.'); }
|
||||
} catch { showNotice('Restore failed. Please try again.'); }
|
||||
restoreBtn.textContent = 'Restore';
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shape and icon library docs (docs/shape-libraries/*.md), the same files
|
||||
* the web app's get_shape_library tool reads (app/api/chat/route.ts).
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = {
|
||||
Cloud: [
|
||||
"aws4",
|
||||
"azure2",
|
||||
"gcp2",
|
||||
"alibaba_cloud",
|
||||
"openstack",
|
||||
"salesforce",
|
||||
],
|
||||
Networking: ["cisco19", "network", "kubernetes", "vvd", "rack"],
|
||||
Business: ["bpmn", "lean_mapping"],
|
||||
General: ["flowchart", "basic", "arrows2", "infographic", "sitemap"],
|
||||
"UI/Mockups": ["android", "material_design"],
|
||||
Enterprise: ["citrix", "sap", "mscae", "atlassian"],
|
||||
Engineering: ["fluidpower", "electrical", "pid", "cabinets", "floorplan"],
|
||||
Icons: ["webicons"],
|
||||
}
|
||||
|
||||
/** The libraries by group, one "- Group: a, b" line each, for tool descriptions */
|
||||
export const SHAPE_LIBRARY_LIST = Object.entries(SHAPE_LIBRARY_GROUPS)
|
||||
.map(([group, names]) => `- ${group}: ${names.join(", ")}`)
|
||||
.join("\n")
|
||||
|
||||
const LIBRARIES = new Set(Object.values(SHAPE_LIBRARY_GROUPS).flat())
|
||||
|
||||
/**
|
||||
* The build copies the docs to dist/shape-libraries; running from src (tsx)
|
||||
* reads them from the repository instead. The web app passes its own folder.
|
||||
*/
|
||||
function libraryDirs(dir?: string): string[] {
|
||||
if (dir) return [dir]
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
return [
|
||||
join(here, "shape-libraries"),
|
||||
resolve(here, "../../../docs/shape-libraries"),
|
||||
]
|
||||
}
|
||||
|
||||
export async function getShapeLibrary(
|
||||
name: string,
|
||||
dir?: string,
|
||||
): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
|
||||
const library = name.trim().toLowerCase()
|
||||
if (!LIBRARIES.has(library)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Library "${name}" not found. Available: ${Array.from(LIBRARIES).join(", ")}`,
|
||||
}
|
||||
}
|
||||
for (const folder of libraryDirs(dir)) {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
text: await readFile(join(folder, `${library}.md`), "utf-8"),
|
||||
}
|
||||
} catch {
|
||||
// Try the next location
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Library "${library}" is missing from this installation.`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The attributes of one tag as written: name="value" or name='value'
|
||||
* pairs. A quoted value is read whole, so text inside it such as
|
||||
* value="Use parent='1'" is never taken for an attribute.
|
||||
*/
|
||||
export interface TagAttribute {
|
||||
name: string
|
||||
value: string
|
||||
// The attribute's text in the tag, with the whitespace before it
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function readAttributes(tag: string): TagAttribute[] {
|
||||
const attributes: TagAttribute[] = []
|
||||
for (const m of tag.matchAll(
|
||||
/\s*([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
|
||||
)) {
|
||||
attributes.push({
|
||||
name: m[1],
|
||||
value: m[2] ?? m[3],
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
})
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* XML examples shared by the MCP drawing guide and the web app's system
|
||||
* prompt and tool descriptions, so both teach the model the same thing.
|
||||
*/
|
||||
|
||||
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`
|
||||
|
||||
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`
|
||||
|
||||
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>`
|
||||
|
||||
/** Indent every line, for an indented code block in Markdown */
|
||||
export const indent = (text: string, prefix = " ") =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((line) => prefix + line)
|
||||
.join("\n")
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Strict XML syntax check, used in Node and in the web app's browser code.
|
||||
* saxes reports syntax errors the way draw.io's DOMParser will, including
|
||||
* an attribute prefix such as xlink: that was never declared.
|
||||
*/
|
||||
import { SaxesParser } from "saxes"
|
||||
|
||||
/**
|
||||
* Returns the first XML syntax error as "line:column: message", or null if
|
||||
* the XML is well-formed. Surrounding whitespace is ignored because every
|
||||
* caller trims before the XML reaches the browser.
|
||||
*/
|
||||
export function getXmlSyntaxError(xml: string): string | null {
|
||||
let error: string | null = null
|
||||
const parser = new SaxesParser({ xmlns: true })
|
||||
parser.on("error", (err) => {
|
||||
error ??= err.message
|
||||
})
|
||||
parser.write(xml.trim()).close()
|
||||
return error
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* Copied from lib/utils.ts to avoid cross-package imports
|
||||
*/
|
||||
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import { readAttributes } from "./xml-attributes.ts"
|
||||
import { getXmlSyntaxError } from "./xml-syntax.ts"
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
@@ -25,6 +26,23 @@ const STRUCTURAL_ATTRS = [
|
||||
/** Valid XML entity names */
|
||||
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
||||
|
||||
/** Element names draw.io understands (case-sensitive) */
|
||||
const VALID_DRAWIO_TAGS = new Set([
|
||||
"mxfile",
|
||||
"diagram",
|
||||
"mxGraphModel",
|
||||
"root",
|
||||
"mxCell",
|
||||
"mxGeometry",
|
||||
"mxPoint",
|
||||
"Array",
|
||||
"Object",
|
||||
"mxRectangle",
|
||||
// Wrappers draw.io writes for cells with links, tooltips or data
|
||||
"UserObject",
|
||||
"object",
|
||||
])
|
||||
|
||||
// ============================================================================
|
||||
// XML Parsing Helpers
|
||||
// ============================================================================
|
||||
@@ -90,6 +108,33 @@ function parseXmlTags(xml: string): ParsedTag[] {
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function telling whether a position lies inside a quoted
|
||||
* attribute value. Positions must be queried in increasing order.
|
||||
*/
|
||||
function createQuoteTracker(str: string): (pos: number) => boolean {
|
||||
let i = 0
|
||||
let inQuote = false
|
||||
let quoteChar = ""
|
||||
return (pos: number) => {
|
||||
for (; i < pos && i < str.length; i++) {
|
||||
const c = str[i]
|
||||
if (inQuote) {
|
||||
if (c === quoteChar) inQuote = false
|
||||
} else if (c === '"' || c === "'") {
|
||||
// Only quotes that follow "=" open an attribute value
|
||||
let j = i - 1
|
||||
while (j >= 0 && /\s/.test(str[j])) j--
|
||||
if (j >= 0 && str[j] === "=") {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return inQuote
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite every opening tag with fn, leaving text and closing tags as is. */
|
||||
function replaceInOpeningTags(
|
||||
xml: string,
|
||||
@@ -112,16 +157,10 @@ function replaceInOpeningTags(
|
||||
/** Check for duplicate structural attributes in a tag */
|
||||
function checkDuplicateAttributes(xml: string): string | null {
|
||||
const structuralSet = new Set(STRUCTURAL_ATTRS)
|
||||
const tagPattern = /<[^>]+>/g
|
||||
let tagMatch
|
||||
while ((tagMatch = tagPattern.exec(xml)) !== null) {
|
||||
const tag = tagMatch[0]
|
||||
const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g
|
||||
for (const [tag] of xml.matchAll(/<[^>]+>/g)) {
|
||||
const attributes = new Map<string, number>()
|
||||
let attrMatch
|
||||
while ((attrMatch = attrPattern.exec(tag)) !== null) {
|
||||
const attrName = attrMatch[1]
|
||||
attributes.set(attrName, (attributes.get(attrName) || 0) + 1)
|
||||
for (const { name } of readAttributes(tag)) {
|
||||
attributes.set(name, (attributes.get(name) || 0) + 1)
|
||||
}
|
||||
const duplicates = Array.from(attributes.entries())
|
||||
.filter(([name, count]) => count > 1 && structuralSet.has(name))
|
||||
@@ -172,15 +211,25 @@ function checkDuplicateIds(xml: string): string | null {
|
||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||
}
|
||||
|
||||
// 2) Within each page, mxCell ids must be unique.
|
||||
// 2) Within each page, cell ids must be unique. A cell with
|
||||
// a link or custom data is a UserObject/object holding the
|
||||
// id, around an mxCell whose own id does not count.
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
const diagram = diagrams[i]
|
||||
const pageId = diagram.getAttribute("id") || `(index ${i})`
|
||||
const cells = diagram.querySelectorAll("mxCell")
|
||||
const cells = diagram.querySelectorAll(
|
||||
"mxCell, UserObject, object",
|
||||
)
|
||||
const cellIds = new Map<string, number>()
|
||||
cells.forEach((c) => {
|
||||
const wrapped =
|
||||
c.tagName === "mxCell" &&
|
||||
/^(UserObject|object)$/.test(
|
||||
c.parentElement?.tagName ?? "",
|
||||
)
|
||||
const id = c.getAttribute("id")
|
||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
if (id && !wrapped)
|
||||
cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dups = Array.from(cellIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
@@ -306,6 +355,38 @@ function checkNestedMxCells(xml: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check for element names draw.io does not know (e.g. a lowercase <mxcell>) */
|
||||
function checkUnknownElements(xml: string): string | null {
|
||||
const tags = parseXmlTags(xml.replace(/<!--[\s\S]*?-->/g, ""))
|
||||
for (const { tagName } of tags) {
|
||||
if (!VALID_DRAWIO_TAGS.has(tagName)) {
|
||||
return `Invalid XML: Unknown element <${tagName}>. draw.io only understands ${Array.from(VALID_DRAWIO_TAGS).join(", ")} (names are case-sensitive).`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find <mxPoint> elements without an "as" attribute outside <Array
|
||||
* as="points">. draw.io rejects them with "Could not add object mxPoint".
|
||||
*/
|
||||
function findOrphanMxPoints(
|
||||
xml: string,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const arrays: Array<[number, number]> = []
|
||||
// (?<!\/) skips an empty <Array/>, which has no points inside
|
||||
for (const m of xml.matchAll(/<Array\b[^>]*(?<!\/)>[\s\S]*?<\/Array>/g)) {
|
||||
arrays.push([m.index, m.index + m[0].length])
|
||||
}
|
||||
const orphans: Array<{ start: number; end: number }> = []
|
||||
for (const m of xml.matchAll(/<mxPoint\b[^>]*?(?:\/>|>\s*<\/mxPoint>)/g)) {
|
||||
if (/\sas\s*=/.test(m[0])) continue
|
||||
if (arrays.some(([s, e]) => m.index > s && m.index < e)) continue
|
||||
orphans.push({ start: m.index, end: m.index + m[0].length })
|
||||
}
|
||||
return orphans
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Validation Function
|
||||
// ============================================================================
|
||||
@@ -314,9 +395,35 @@ function checkNestedMxCells(xml: string): string | null {
|
||||
* Validates draw.io XML structure for common issues
|
||||
* Uses DOM parsing + additional regex checks for high accuracy
|
||||
* @param xml - The XML string to validate
|
||||
* @param opts.strict - Also reject unknown element names and orphan
|
||||
* <mxPoint>s. Used for XML the model wrote, not for files or browser state.
|
||||
* @returns null if valid, error message string if invalid
|
||||
*/
|
||||
export function validateMxCellStructure(xml: string): string | null {
|
||||
/** The first non-blank text under el, skipping a page's compressed data */
|
||||
function findTextBetweenTags(el: Element | null): string | null {
|
||||
if (!el) return null
|
||||
// A <diagram> with only text holds the page compressed
|
||||
const compressed = el.tagName === "diagram" && el.children.length === 0
|
||||
for (const node of Array.from(el.childNodes)) {
|
||||
if (node.nodeType === 1) {
|
||||
const text = findTextBetweenTags(node as Element)
|
||||
if (text) return text
|
||||
} else if (
|
||||
// Text, or a CDATA section (draw.io reads it as text too)
|
||||
(node.nodeType === 3 || node.nodeType === 4) &&
|
||||
!compressed
|
||||
) {
|
||||
const text = node.textContent?.trim()
|
||||
if (text) return text.slice(0, 40)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateMxCellStructure(
|
||||
xml: string,
|
||||
opts: { strict?: boolean } = {},
|
||||
): string | null {
|
||||
// Size check for performance
|
||||
if (xml.length > MAX_XML_SIZE) {
|
||||
console.warn(
|
||||
@@ -338,6 +445,15 @@ export function validateMxCellStructure(xml: string): string | null {
|
||||
return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.`
|
||||
}
|
||||
}
|
||||
|
||||
// draw.io reads any text inside a page as compressed page data and
|
||||
// then fails to open the page
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
const text = findTextBetweenTags(doc.documentElement)
|
||||
if (text) {
|
||||
return `Invalid XML: Found text "${text}" between tags. Labels belong in the value attribute; remove any other text between tags.`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:",
|
||||
@@ -410,6 +526,16 @@ export function validateMxCellStructure(xml: string): string | null {
|
||||
return nestedCellError
|
||||
}
|
||||
|
||||
if (opts.strict) {
|
||||
const unknownError = checkUnknownElements(xml)
|
||||
if (unknownError) {
|
||||
return unknownError
|
||||
}
|
||||
if (findOrphanMxPoints(xml).length > 0) {
|
||||
return 'Invalid XML: Found <mxPoint> without an "as" attribute outside <Array as="points">. Put waypoints inside <Array as="points"> or remove the point.'
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Strict XML syntax check, run last so the checks above can give
|
||||
// more specific messages. Catches what they miss, e.g. duplicate or
|
||||
// unquoted attributes, which make draw.io refuse to load the diagram.
|
||||
@@ -441,6 +567,15 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Fixed JSON-escaped XML")
|
||||
}
|
||||
|
||||
// 0b. Literal \n, \t or \r between tags, from escaping the XML twice
|
||||
const unescaped = fixed.replace(/>(?:\s|\\[nrt])+</g, (gap) =>
|
||||
gap.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""),
|
||||
)
|
||||
if (unescaped !== fixed) {
|
||||
fixed = unescaped
|
||||
fixes.push("Replaced literal \\n between tags with line breaks")
|
||||
}
|
||||
|
||||
// 1. Remove CDATA wrapper
|
||||
if (/^\s*<!\[CDATA\[/.test(fixed)) {
|
||||
fixed = fixed.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, "")
|
||||
@@ -456,27 +591,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
|
||||
// 3. Fix duplicate attributes
|
||||
let dupAttrFixed = false
|
||||
const structural = new Set(STRUCTURAL_ATTRS)
|
||||
fixed = fixed.replace(/<[^>]+>/g, (tag) => {
|
||||
let newTag = tag
|
||||
for (const attr of STRUCTURAL_ATTRS) {
|
||||
const attrRegex = new RegExp(
|
||||
`\\s${attr}\\s*=\\s*["'][^"']*["']`,
|
||||
"gi",
|
||||
)
|
||||
const matches = tag.match(attrRegex)
|
||||
if (matches && matches.length > 1) {
|
||||
let firstKept = false
|
||||
newTag = newTag.replace(attrRegex, (m) => {
|
||||
if (!firstKept) {
|
||||
firstKept = true
|
||||
return m
|
||||
}
|
||||
dupAttrFixed = true
|
||||
return ""
|
||||
})
|
||||
// Keep the first of each, drop the later ones
|
||||
const seen = new Set<string>()
|
||||
let newTag = ""
|
||||
let last = 0
|
||||
for (const attr of readAttributes(tag)) {
|
||||
if (!structural.has(attr.name)) continue
|
||||
if (!seen.has(attr.name)) {
|
||||
seen.add(attr.name)
|
||||
continue
|
||||
}
|
||||
newTag += tag.slice(last, attr.start)
|
||||
last = attr.end
|
||||
dupAttrFixed = true
|
||||
}
|
||||
return newTag
|
||||
return newTag + tag.slice(last)
|
||||
})
|
||||
if (dupAttrFixed) {
|
||||
fixes.push("Removed duplicate structural attributes")
|
||||
@@ -629,65 +760,67 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Fixed <Cell> tags to <mxCell>")
|
||||
}
|
||||
|
||||
// 15. Fix common closing tag typos (MUST run before foreign tag removal)
|
||||
const tagTypos = [
|
||||
{ wrong: /<\/mxElement>/gi, right: "</mxCell>", name: "</mxElement>" },
|
||||
{ wrong: /<\/mxcell>/g, right: "</mxCell>", name: "</mxcell>" },
|
||||
{
|
||||
wrong: /<\/mxgeometry>/g,
|
||||
right: "</mxGeometry>",
|
||||
name: "</mxgeometry>",
|
||||
},
|
||||
{ wrong: /<\/mxpoint>/g, right: "</mxPoint>", name: "</mxpoint>" },
|
||||
{
|
||||
wrong: /<\/mxgraphmodel>/gi,
|
||||
right: "</mxGraphModel>",
|
||||
name: "</mxgraphmodel>",
|
||||
},
|
||||
]
|
||||
for (const { wrong, right, name } of tagTypos) {
|
||||
const before = fixed
|
||||
fixed = fixed.replace(wrong, right)
|
||||
if (fixed !== before) {
|
||||
fixes.push(`Fixed typo ${name} to ${right}`)
|
||||
// 15. Fix closing tag typos and wrong tag case, e.g. <mxcell> (MUST run
|
||||
// before foreign tag removal, which would otherwise delete them)
|
||||
const before15 = fixed
|
||||
fixed = fixed.replace(/<\/mxElement>/gi, "</mxCell>")
|
||||
if (fixed !== before15) {
|
||||
fixes.push("Fixed typo </mxElement> to </mxCell>")
|
||||
}
|
||||
for (const name of ["mxCell", "mxGeometry", "mxPoint", "mxGraphModel"]) {
|
||||
let changed = false
|
||||
fixed = fixed.replace(
|
||||
new RegExp(`<(/?)${name}(?=[\\s/>])`, "gi"),
|
||||
(match, slash) => {
|
||||
const right = `<${slash}${name}`
|
||||
if (match !== right) changed = true
|
||||
return right
|
||||
},
|
||||
)
|
||||
if (changed) {
|
||||
fixes.push(`Fixed tag case of <${name}>`)
|
||||
}
|
||||
}
|
||||
|
||||
// 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
||||
const validDrawioTags = new Set([
|
||||
"mxfile",
|
||||
"diagram",
|
||||
"mxGraphModel",
|
||||
"root",
|
||||
"mxCell",
|
||||
"mxGeometry",
|
||||
"mxPoint",
|
||||
"Array",
|
||||
"Object",
|
||||
"mxRectangle",
|
||||
// Wrappers draw.io writes for cells with links, tooltips or data
|
||||
"UserObject",
|
||||
"object",
|
||||
])
|
||||
// 16. Remove non-draw.io tags (after the case fixes above). Removes only
|
||||
// the exact tag occurrences and skips quoted attribute values, so a stray
|
||||
// <mxGraph/> never takes <mxGraphModel> with it and <b> inside
|
||||
// value="..." stays.
|
||||
const isInsideQuotesFor16 = createQuoteTracker(fixed)
|
||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||
let foreignMatch
|
||||
const foreignTags = new Set<string>()
|
||||
const foreignTagPositions: Array<{ start: number; end: number }> = []
|
||||
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
||||
const tagName = foreignMatch[1]
|
||||
if (!validDrawioTags.has(tagName)) {
|
||||
foreignTags.add(tagName)
|
||||
}
|
||||
if (VALID_DRAWIO_TAGS.has(tagName)) continue
|
||||
if (isInsideQuotesFor16(foreignMatch.index)) continue
|
||||
foreignTags.add(tagName)
|
||||
foreignTagPositions.push({
|
||||
start: foreignMatch.index,
|
||||
end: foreignMatch.index + foreignMatch[0].length,
|
||||
})
|
||||
}
|
||||
if (foreignTags.size > 0) {
|
||||
for (const tag of foreignTags) {
|
||||
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
|
||||
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
|
||||
if (foreignTagPositions.length > 0) {
|
||||
// Remove from the end so earlier positions stay valid
|
||||
for (const { start, end } of foreignTagPositions.reverse()) {
|
||||
fixed = fixed.slice(0, start) + fixed.slice(end)
|
||||
}
|
||||
fixes.push(
|
||||
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 16b. Remove orphan <mxPoint>s (no "as" attribute, not inside
|
||||
// <Array as="points">), which draw.io refuses to load
|
||||
const orphanPoints = findOrphanMxPoints(fixed)
|
||||
if (orphanPoints.length > 0) {
|
||||
for (const { start, end } of orphanPoints.reverse()) {
|
||||
fixed = fixed.slice(0, start) + fixed.slice(end)
|
||||
}
|
||||
fixes.push(`Removed ${orphanPoints.length} orphan <mxPoint>(s)`)
|
||||
}
|
||||
|
||||
// 17. Fix unclosed tags
|
||||
const tagStack: string[] = []
|
||||
const parsedTags = parseXmlTags(fixed)
|
||||
@@ -725,19 +858,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
}
|
||||
|
||||
// 18. Remove extra closing tags
|
||||
// 18. Remove extra closing tags. Counts only draw.io tags outside quoted
|
||||
// attribute values (value="<b>Title</b>" holds HTML, not elements).
|
||||
const tagCounts = new Map<
|
||||
string,
|
||||
{ opens: number; closes: number; selfClosing: number }
|
||||
>()
|
||||
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
const isInsideQuotesFor18 = createQuoteTracker(fixed)
|
||||
let tagCountMatch
|
||||
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||
if (isInsideQuotesFor18(tagCountMatch.index)) continue
|
||||
const fullMatch = tagCountMatch[0]
|
||||
const tagPart = tagCountMatch[1]
|
||||
const isClosing = tagPart.startsWith("/")
|
||||
const isSelfClosing = fullMatch.endsWith("/>")
|
||||
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
||||
if (!VALID_DRAWIO_TAGS.has(tagName)) continue
|
||||
|
||||
let counts = tagCounts.get(tagName)
|
||||
if (!counts) {
|
||||
@@ -943,18 +1080,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validates XML and attempts to fix if invalid
|
||||
* Validates XML and attempts to fix if invalid. By default runs the strict
|
||||
* checks (unknown elements, orphan mxPoints), meant for XML the model wrote.
|
||||
* Pass strict: false for a diagram that also holds the user's own content.
|
||||
* @param xml - The XML string to validate and potentially fix
|
||||
* @returns Object with validation result, fixed XML if applicable, and fixes applied
|
||||
*/
|
||||
export function validateAndFixXml(xml: string): {
|
||||
export function validateAndFixXml(
|
||||
xml: string,
|
||||
{ strict = true }: { strict?: boolean } = {},
|
||||
): {
|
||||
valid: boolean
|
||||
error: string | null
|
||||
fixed: string | null
|
||||
fixes: string[]
|
||||
} {
|
||||
// First validation attempt
|
||||
let error = validateMxCellStructure(xml)
|
||||
let error = validateMxCellStructure(xml, { strict })
|
||||
|
||||
if (!error) {
|
||||
return { valid: true, error: null, fixed: null, fixes: [] }
|
||||
@@ -964,7 +1106,7 @@ export function validateAndFixXml(xml: string): {
|
||||
const { fixed, fixes } = autoFixXml(xml)
|
||||
|
||||
// Validate the fixed version
|
||||
error = validateMxCellStructure(fixed)
|
||||
error = validateMxCellStructure(fixed, { strict })
|
||||
|
||||
if (!error) {
|
||||
return { valid: true, error: null, fixed, fixes }
|
||||
@@ -978,33 +1120,3 @@ export function validateAndFixXml(xml: string): {
|
||||
fixes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mxCell XML output is complete (not truncated).
|
||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||
* @param xml - The XML string to check (can be undefined/null)
|
||||
* @returns true if XML appears complete, false if truncated or empty
|
||||
*/
|
||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||
const trimmed = xml?.trim() || ""
|
||||
if (!trimmed) return false
|
||||
|
||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||
|
||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||
|
||||
// No valid ending found at all
|
||||
if (lastValidEnd === -1) return false
|
||||
|
||||
// Check what comes after the last valid ending
|
||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||
|
||||
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
* The id sits on the wrapper; the inner mxCell has none.
|
||||
*/
|
||||
|
||||
import { deflateRawSync } from "node:zlib"
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.ts"
|
||||
|
||||
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
@@ -83,3 +84,68 @@ describe("cascade delete logging", () => {
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages without a <root>", () => {
|
||||
const ADD_A = {
|
||||
operation: "add" as const,
|
||||
cell_id: "a",
|
||||
new_xml: `<mxCell id="a" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
|
||||
}
|
||||
|
||||
it("treats an empty page as a blank page", () => {
|
||||
const doc = `<mxfile><diagram id="p" name="Page-1"></diagram></mxfile>`
|
||||
const { result, errors } = applyDiagramOperations(doc, [ADD_A])
|
||||
expect(errors).toEqual([])
|
||||
expect(result).toContain('<mxCell id="0"/>')
|
||||
expect(result).toContain('<mxCell id="1" parent="0"/>')
|
||||
expect(result).toContain('<mxCell id="a"')
|
||||
})
|
||||
|
||||
it("decompresses a compressed page before editing it", () => {
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="b" vertex="1" parent="1"/></root></mxGraphModel>`
|
||||
const compressed = deflateRawSync(
|
||||
Buffer.from(encodeURIComponent(model)),
|
||||
).toString("base64")
|
||||
const doc = `<mxfile><diagram id="p" name="Page-1">${compressed}</diagram></mxfile>`
|
||||
const { result, errors } = applyDiagramOperations(doc, [ADD_A])
|
||||
expect(errors).toEqual([])
|
||||
expect(result).not.toContain(compressed)
|
||||
expect(result).toContain('<mxCell id="b"')
|
||||
expect(result).toContain('<mxCell id="a"')
|
||||
// Only one model and one set of root cells
|
||||
expect(result.match(/<mxGraphModel/g)).toHaveLength(1)
|
||||
expect(result.match(/<mxCell id="0"/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("reports a page whose text is not compressed XML", () => {
|
||||
const doc = `<mxfile><diagram id="p" name="Page-1">not base64 !!</diagram></mxfile>`
|
||||
const { errors } = applyDiagramOperations(doc, [ADD_A])
|
||||
expect(errors[0]?.cellId).toBe("")
|
||||
expect(errors[0]?.message).toContain("could not be decompressed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("a wrapped mxCell with its wrapper's id", () => {
|
||||
const doc = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" label="A" link="https://example.com"><mxCell id="u" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
it("deletes the whole wrapper", () => {
|
||||
const { result, errors } = applyDiagramOperations(doc, [
|
||||
{ operation: "delete", cell_id: "u" },
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
expect(result).not.toContain("UserObject")
|
||||
})
|
||||
|
||||
it("replaces the wrapper on update", () => {
|
||||
const { result, errors } = applyDiagramOperations(doc, [
|
||||
{
|
||||
operation: "update",
|
||||
cell_id: "u",
|
||||
new_xml: `<UserObject id="u" label="B" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
expect(result.match(/<UserObject/g)).toHaveLength(1)
|
||||
expect(result).toContain('label="B"')
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user