@@ -77,92 +473,53 @@ export default function About() {
{/* Bring Your Own Key */}
- Bring Your Own API Key
+ {t.byok.title}
- You can also use your own API key with any
- supported provider. Click the Settings icon
- in the chat panel to configure your provider
- and API key.
+ {t.byok.body}
- Your key is stored locally in your browser
- and is never stored on the server.
+ {t.byok.note}
-
- A Next.js web application that integrates AI
- capabilities with draw.io diagrams. Create, modify, and
- enhance diagrams through natural language commands and
- AI-assisted visualization.
-
+
{t.intro}
{/* Features */}
- Features
+ {t.featuresHeading}
-
- LLM-Powered Diagram Creation:
- Leverage Large Language Models to create and
- manipulate draw.io diagrams directly through natural
- language commands
-
-
- Image-Based Diagram Replication:
- Upload existing diagrams or images and have the AI
- replicate and enhance them automatically
-
-
- Versions and Undo: Every AI change
- becomes a version in the chat that you can compare,
- restore or undo; Ctrl+Z on the canvas takes it back
- too
-
-
- Interactive Chat Interface:
- Communicate with AI to refine your diagrams in
- real-time
-
-
- AWS Architecture Diagram Support:
- Specialized support for generating AWS architecture
- diagrams
-
-
- Animated Connectors: Create dynamic
- and animated connectors between diagram elements for
- better visualization
-
+ {t.features.map((feature) => (
+
+ {feature.title}
+ {t.colon}
+ {feature.text}
+
+ ))}
{/* Examples */}
- Examples
+ {t.examplesHeading}
-
- Here are some example prompts and their generated
- diagrams:
-
+
{t.examplesIntro}
- {/* ResNet50 Architecture */}
+ {/* First example on its own */}
- Animated ResNet50 Model Architecture
+ {t.examples[hero.id].title}
- Prompt: Give me an{" "}
- animated architecture diagram
- of the ResNet50 model.
+ Prompt: {hero.prompt}
-
-
- RAG Technique Diagram
-
-
- Prompt: Generate a RAG
- architecture diagram for{" "}
- chat application. Use
- connected diagram for data ingestion
-
-
-
+ {grid.map((example) => (
+
+
+ {t.examples[example.id].title}
+
+
+ Prompt:{" "}
+ {example.prompt}
+
+
+
+
-
-
-
- Authentication using React and AWS
-
-
- Prompt: Generate
- authentication process using React with{" "}
- AWS. Use Serverless
- architecture.
-
-
-
-
-
-
-
- Agile Scrum Process
-
-
- Prompt: Generate agile
- scrum workflow diagram for software
- development team.
-
-
-
-
-
-
-
- Open Innovation
-
-
- Prompt: Create
- visualization of Henry Chesbrough's
- Open Innovation model.
-
-
-
-
-
+ ))}
{/* How It Works */}
- How It Works
+ {t.howHeading}
-
- The application uses the following technologies:
-
+
{t.howIntro}
- Next.js: For the frontend framework
- and routing
+ Next.js
+ {t.colon}
+ {t.tech.nextjs}
- Vercel AI SDK (ai +{" "}
- @ai-sdk/*): For streaming AI responses
- and multi-provider support
+ Vercel AI SDK
+ {t.parenOpen}
+ ai + @ai-sdk/*
+ {t.parenClose}
+ {t.colon}
+ {t.tech.aiSdk}
- draw.io: The editor, bundled with
- the app so it can be driven directly
+ draw.io
+ {t.colon}
+ {t.tech.drawio}
-
- Diagrams are represented as XML that can be rendered in
- draw.io. The AI processes your commands and generates or
- modifies this XML accordingly.
-
@@ -303,48 +602,47 @@ export default function About() {
ModelScope
- Note that claude-sonnet-4-5 has trained on
- draw.io diagrams with AWS logos, so if you want to
- create AWS architecture diagrams, this is the best
- choice.
+ {t.modelNote.before}
+ claude-sonnet-4-5
+ {t.modelNote.after}
{/* Support */}
- Support & Contact
+ {t.supportHeading}
- If you find this project useful, please consider{" "}
+ {t.sponsor.before}{" "}
- sponsoring
+ {t.sponsor.link}
{" "}
- to help host the live demo site!
+ {t.sponsor.after}
- For support or inquiries, please open an issue on the{" "}
+ {t.contact.before}{" "}
- GitHub repository
+ {t.contact.link}
{" "}
- or contact: me[at]jiang.jp
+ {t.contact.after}
{/* CTA */}
- Open Editor
+ {t.openEditor}
@@ -354,8 +652,7 @@ export default function About() {
diff --git a/app/[lang]/admin/admin-shared.ts b/app/[lang]/admin/admin-shared.ts
index fc93310e..fc8b5284 100644
--- a/app/[lang]/admin/admin-shared.ts
+++ b/app/[lang]/admin/admin-shared.ts
@@ -1,3 +1,5 @@
+import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
+import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
-export type SecretValue = { isSet: true; hint: string }
+// The same shapes the admin API validates and returns. Secret fields of a
+// provider hold either a masked marker (unchanged) or a plaintext string
+// (new value).
+export type { AdminProvider, SecretValue }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
-// Admin provider in client state. Secret fields hold either a masked
-// marker (unchanged) or a plaintext string (new value).
-export interface AdminProvider {
- id: string
- provider: ProviderName
- name?: string
- apiKey?: string | SecretValue
- baseUrl?: string
- awsAccessKeyId?: string | SecretValue
- awsSecretAccessKey?: string | SecretValue
- awsRegion?: string
- vertexApiKey?: string | SecretValue
- models: string[]
- isDefault?: boolean
-}
-
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
diff --git a/app/[lang]/admin/setting-field.tsx b/app/[lang]/admin/setting-field.tsx
index 2bd02325..d11be1ce 100644
--- a/app/[lang]/admin/setting-field.tsx
+++ b/app/[lang]/admin/setting-field.tsx
@@ -3,13 +3,6 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
@@ -206,26 +199,6 @@ export function SettingField({
)
break
}
- case "enum":
- control = (
-
- )
- break
case "secret":
control = (
diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx
index 2e09e6b9..ede4a684 100644
--- a/app/[lang]/layout.tsx
+++ b/app/[lang]/layout.tsx
@@ -41,6 +41,13 @@ export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }))
}
+const OG_LOCALE: Record = {
+ en: "en_US",
+ zh: "zh_CN",
+ ja: "ja_JP",
+ "zh-Hant": "zh_HK",
+}
+
// Generate metadata per locale
export async function generateMetadata({
params,
@@ -48,22 +55,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }>
}): Promise {
const { lang: rawLang } = await params
- const lang = (
- rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
- ) as Locale
+ const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
- // Default to English metadata
const titles: Record = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
- zh: "Next AI Draw.io - AI powered diagram generator",
- ja: "Next AI Draw.io - AI-powered diagram generator",
+ zh: "Next AI Draw.io - AI 驱动的图表生成器",
+ ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
}
const descriptions: Record = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
- zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
- ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
+ zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
+ ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
}
@@ -92,14 +96,7 @@ export async function generateMetadata({
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
- locale:
- lang === "zh"
- ? "zh_CN"
- : lang === "zh-Hant"
- ? "zh_HK"
- : lang === "ja"
- ? "ja_JP"
- : "en_US",
+ locale: OG_LOCALE[lang],
images: [
{
url: "/architecture.png",
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts
index d4d09755..183c0777 100644
--- a/app/api/admin/settings/route.ts
+++ b/app/api/admin/settings/route.ts
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
- case "enum":
- return def.options?.includes(value)
- ? null
- : `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
diff --git a/app/api/admin/test-model/route.ts b/app/api/admin/test-model/route.ts
index 51999ca8..4cb886b1 100644
--- a/app/api/admin/test-model/route.ts
+++ b/app/api/admin/test-model/route.ts
@@ -1,4 +1,3 @@
-import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
@@ -6,6 +5,7 @@ import {
mergeSecrets,
} from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers"
+import { testModelConnection } from "@/lib/test-model-connection"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [],
)
+ // Without a URL of its own, chat sends the entry's key to the server's
+ //
_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.
+ // The admin's own request supplies the EdgeOne endpoint, cookies and
+ // access code, as a chat request would.
const serverUrl = globalBaseUrl(resolved.provider)
- return validateModel(
- new Request(new URL("/api/validate-model", req.url), {
- method: "POST",
- 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,
- // Without a URL of its own, chat sends the entry's key to
- // the server's
_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,
- awsRegion: resolved.awsRegion,
- vertexApiKey: resolved.vertexApiKey,
- }),
- }),
+ return testModelConnection(
+ req,
+ {
+ provider: resolved.provider,
+ apiKey: resolved.apiKey,
+ baseUrl: resolved.baseUrl || serverUrl,
+ modelId: body.modelId,
+ awsAccessKeyId: resolved.awsAccessKeyId,
+ awsSecretAccessKey: resolved.awsSecretAccessKey,
+ awsRegion: resolved.awsRegion,
+ vertexApiKey: resolved.vertexApiKey,
+ },
+ !resolved.baseUrl && !!serverUrl,
)
}
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index ebd56283..4ea92508 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -1,8 +1,6 @@
import {
APICallError,
convertToModelMessages,
- createUIMessageStream,
- createUIMessageStreamResponse,
InvalidToolInputError,
stepCountIs,
streamText,
@@ -21,12 +19,10 @@ import {
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
-import { findCachedResponse } from "@/lib/cached-responses"
import {
dropInvalidToolCalls,
fixToolInputJson,
formatSelectionContext,
- replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
@@ -56,7 +52,6 @@ import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
-import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
@@ -70,36 +65,6 @@ import {
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
// which is also where Node's response-body timeout on the upstream stream lands.
-// Helper function to create cached stream response
-function createCachedStreamResponse(xml: string): Response {
- const toolCallId = `cached-${Date.now()}`
-
- const stream = createUIMessageStream({
- execute: async ({ writer }) => {
- writer.write({ type: "start" })
- writer.write({
- type: "tool-input-start",
- toolCallId,
- toolName: "display_diagram",
- })
- writer.write({
- type: "tool-input-delta",
- toolCallId,
- inputTextDelta: xml,
- })
- writer.write({
- type: "tool-input-available",
- toolCallId,
- toolName: "display_diagram",
- input: { xml },
- })
- writer.write({ type: "finish" })
- },
- })
-
- return createUIMessageStreamResponse({ stream })
-}
-
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet()
// A Request's signal follows the client's disconnect only while the Request
@@ -111,6 +76,21 @@ const requestOfResponse = new WeakMap()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
+// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
+// and input keys, without the diagram XML or image data
+const describeMessages = (msgs: any[]) =>
+ msgs.map((m) => ({
+ role: m.role,
+ parts: (m.parts ?? m.content ?? []).map(
+ (p: any) =>
+ p.type +
+ (p.toolName ? `:${p.toolName}` : "") +
+ (p.input && typeof p.input === "object"
+ ? `(${Object.keys(p.input).join(",")})`
+ : ""),
+ ),
+ }))
+
async function handleChatRequest(req: Request): Promise {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
@@ -156,23 +136,6 @@ async function handleChatRequest(req: Request): Promise {
}
// === FILE VALIDATION END ===
- // === CACHE CHECK START ===
- const isFirstMessage = messages.length === 1
- const isEmptyDiagram = !xml || !hasCells(xml)
-
- if (isFirstMessage && isEmptyDiagram) {
- const lastMessage = messages[0]
- const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
- const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
-
- const cached = findCachedResponse(textPart?.text || "", !!filePart)
-
- if (cached) {
- return createCachedStreamResponse(cached.xml)
- }
- }
- // === CACHE CHECK END ===
-
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
@@ -348,7 +311,7 @@ async function handleChatRequest(req: Request): Promise {
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
)
- // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
+ // System prompt, with the model name filled in and the style section chosen
const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
@@ -380,47 +343,15 @@ ${userInputText}
// 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,
- })
- }
- })
- }
- })
+ console.log(
+ "[route.ts] incoming",
+ JSON.stringify(describeMessages(messages)),
+ )
}
- // 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 =
- process.env.ENABLE_HISTORY_XML_REPLACE === "true"
- const placeholderMessages = enableHistoryReplace
- ? replaceHistoricalToolInputs(modelMessages)
- : modelMessages
-
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
- let enhancedMessages = placeholderMessages.filter(
+ let enhancedMessages = modelMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
@@ -432,36 +363,10 @@ ${userInputText}
// 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",
- })
- }
- })
- }
- })
+ console.log(
+ "[route.ts] to model",
+ JSON.stringify(describeMessages(enhancedMessages)),
+ )
}
// Update the last message with user input only (XML moved to separate cached system message)
@@ -790,17 +695,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
// 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
- // AI SDK 6 provides totalTokens directly
- return {
- totalTokens: usage?.totalTokens ?? 0,
- finishReason: (part as any).finishReason,
- }
- }
- return undefined
- },
})
modelStreamResponses.add(response)
requestOfResponse.set(response, req)
@@ -840,7 +734,7 @@ async function safeHandler(req: Request): Promise {
} catch (error) {
response = handleError(error)
}
- // Early returns, cache hits and errors never reach streamText's callbacks,
+ // Early returns and errors never reach streamText's callbacks,
// so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace()
return response
diff --git a/app/api/chat/xml_guide.md b/app/api/chat/xml_guide.md
deleted file mode 100644
index e76edf66..00000000
--- a/app/api/chat/xml_guide.md
+++ /dev/null
@@ -1,321 +0,0 @@
-# Draw.io XML Schema Guide
-
-This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
-
-## Basic Structure
-
-A draw.io XML file has the following hierarchy:
-
-```xml
-
-
-
-
-
-
-
-
-
-```
-
-## Root Element: ``
-
-The root element of a draw.io file.
-
-**Attributes:**
-
-- `host`: The application that created the file (e.g., "app.diagrams.net")
-- `modified`: Last modification timestamp
-- `agent`: Browser / user agent information
-- `version`: Version of the application
-- `type`: File type (usually "device" or "google")
-
-**Example:**
-
-```xml
-
-```
-
-## Diagram Element: ``
-
-Each page in your draw.io document is represented by a `` element.
-
-**Attributes:**
-
-- `id`: Unique identifier for the diagram
-- `name`: The name of the diagram / page
-
-**Example:**
-
-```xml
-
-```
-
-## Graph Model: ``
-
-Contains the actual diagram data.
-
-**Attributes:**
-
-- `dx`: Grid size in x-direction (usually 1)
-- `dy`: Grid size in y-direction (usually 1)
-- `grid`: Whether grid is enabled (0 or 1)
-- `gridSize`: Grid cell size (usually 10)
-- `guides`: Whether guides are enabled (0 or 1)
-- `tooltips`: Whether tooltips are enabled (0 or 1)
-- `connect`: Whether connections are enabled (0 or 1)
-- `arrows`: Whether arrows are enabled (0 or 1)
-- `fold`: Whether folding is enabled (0 or 1)
-- `page`: Whether page view is enabled (0 or 1)
-- `pageScale`: Scale of the page (usually 1)
-- `pageWidth`: Width of the page (e.g., 850)
-- `pageHeight`: Height of the page (e.g., 1100)
-- `math`: Whether math typesetting is enabled (0 or 1)
-- `shadow`: Whether shadows are enabled (0 or 1)
-
-**Example:**
-
-```xml
-
-```
-
-## Root Cell Container: ``
-
-Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
-
-**Internal structure (auto-generated):**
-
-```xml
-
-
-
-
-
-```
-
-## Cell Elements: ``
-
-The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
-
-**Attributes for all cells:**
-
-- `id`: Unique identifier for the cell
-- `parent`: ID of the parent cell (typically "1" for most cells)
-- `value`: Text content of the cell
-- `style`: Styling information (see Style section below)
-
-**Attributes for shapes (vertices):**
-
-- `vertex`: Set to "1" for shapes
- - `connectable`: Whether the shape can be connected (0 or 1)
-
-**Attributes for connectors (edges):**
-
-- `edge`: Set to "1" for connectors
- - `source`: ID of the source cell
- - `target`: ID of the target cell
-
-**Example (Rectangle shape):**
-
-```xml
-
-
-
-```
-
-**Example (Connector):**
-
-```xml
-
-
-
-
-
-
-```
-
-## Geometry: ``
-
-Defines the position and dimensions of cells.
-
-**Attributes for shapes:**
-
-- `x`: The x-coordinate of the **top-left** point of the shape.
-- `y`: The y-coordinate of the **top-left** point of the shape.
-- `width`: The width of the shape.
-- `height`: The height of the shape.
-- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
-
-**Attributes for connectors:**
-
-- `relative`: Set to "1" for relative geometry
-- `as`: Set to "geometry"
-
-**Example for shapes:**
-
-```xml
-
-```
-
-**Example for connectors:**
-
-```xml
-
-
-
-
-```
-
-## Cell Style Reference
-
-Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `` elements.
-
-### Shape-specific Styles
-
-- Rectangle: `shape=rectangle`
-- Ellipse: `shape=ellipse`
-- Triangle: `shape=triangle`
-- Rhombus: `shape=rhombus`
-- Hexagon: `shape=hexagon`
-- Cloud: `shape=cloud`
-- Actor: `shape=actor`
-- Cylinder: `shape=cylinder`
-- Document: `shape=document`
-- Note: `shape=note`
-- Card: `shape=card`
-- Parallelogram: `shape=parallelogram`
-
-### Connector Styles
-
-- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
-- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
-- `curved=1`: Curved connector (0 or 1)
-- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
-- `elbow=vertical`: Elbow direction (vertical, horizontal)
-- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
-- `jumpSize=10`: Size of the jump
-
-## Special Cells
-
-Draw.io files contain two special cells that are always present:
-
-1. **Root Cell** (id = "0"): The parent of all cells
-2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
-
-## Tips for Creating Draw.io XML
-
-1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
-2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
-3. Assign unique and sequential IDs to all cells
-4. Define parent relationships correctly (use parent="1" for top-level shapes)
-5. Use `mxGeometry` elements to position shapes
-6. For connectors, specify `source` and `target` attributes
-7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
-
-## Common Patterns
-
-### Grouping Elements
-
-To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Swimlanes
-
-Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under ``. Edges are NOT nested inside swimlanes or steps.**
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### Tables
-
-Tables use multiple cells with parent-child relationships:
-
-```xml
-
-
-
-
-
-
-```
-
-## Advanced Features
-
-### Custom Attributes
-
-Draw.io allows adding custom attributes to cells:
-
-```xml
-
-
-
-
-```
-
-These custom attributes can store additional metadata or be used by plugins and custom behaviors.
-
-### User-defined Styles
-
-You can define custom styles for cells by combining various style attributes:
-
-```xml
-
-
-
-```
-
-### Layers
-
-You can create multiple layers in a diagram to organize complex diagrams:
-
-```xml
-
-
-
-
-
-
-
-
-
-
-```
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index f4b5bc4e..f4dac222 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -3,8 +3,5 @@ import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json({
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
- dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,
- dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,
- tpmLimit: Number(process.env.TPM_LIMIT) || 0,
})
}
diff --git a/app/api/log-feedback/route.ts b/app/api/log-feedback/route.ts
index cfa41ca3..e7cfa7dd 100644
--- a/app/api/log-feedback/route.ts
+++ b/app/api/log-feedback/route.ts
@@ -1,6 +1,5 @@
-import { randomUUID } from "crypto"
import { z } from "zod"
-import { getLangfuseClient } from "@/lib/langfuse"
+import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
const userId = getUserIdFromRequest(req)
try {
- // Find the most recent chat trace for this session to attach the score to
- const tracesResponse = await langfuse.api.trace.list({
+ // Score the session's latest chat trace; without one, a standalone
+ // feedback trace carries the score
+ await scoreLatestTrace(langfuse, {
sessionId,
- limit: 1,
+ name: "user-feedback",
+ value: feedback === "good" ? 1 : 0,
+ comment: `User gave ${feedback} feedback`,
+ fallbackTrace: {
+ name: "user-feedback",
+ userId,
+ input: { messageId, feedback },
+ metadata: {
+ source: "feedback-button",
+ note: "standalone - no chat trace found",
+ },
+ },
})
- const traces = tracesResponse.data || []
- const latestTrace = traces[0]
-
- if (!latestTrace) {
- // No trace found for this session - create a standalone feedback trace
- const traceId = randomUUID()
- const timestamp = new Date().toISOString()
-
- await langfuse.api.ingestion.batch({
- batch: [
- {
- type: "trace-create",
- id: randomUUID(),
- timestamp,
- body: {
- id: traceId,
- name: "user-feedback",
- sessionId,
- userId,
- input: { messageId, feedback },
- metadata: {
- source: "feedback-button",
- note: "standalone - no chat trace found",
- },
- timestamp,
- },
- },
- {
- type: "score-create",
- id: randomUUID(),
- timestamp,
- body: {
- id: randomUUID(),
- traceId,
- name: "user-feedback",
- value: feedback === "good" ? 1 : 0,
- comment: `User gave ${feedback} feedback`,
- },
- },
- ],
- })
- } else {
- // Attach score to the existing chat trace
- const timestamp = new Date().toISOString()
-
- await langfuse.api.ingestion.batch({
- batch: [
- {
- type: "score-create",
- id: randomUUID(),
- timestamp,
- body: {
- id: randomUUID(),
- traceId: latestTrace.id,
- name: "user-feedback",
- value: feedback === "good" ? 1 : 0,
- comment: `User gave ${feedback} feedback`,
- },
- },
- ],
- })
- }
-
return Response.json({ success: true, logged: true })
} catch (error) {
console.error("Langfuse feedback error:", error)
diff --git a/app/api/log-save/route.ts b/app/api/log-save/route.ts
index eb30e0fe..cd97e2d2 100644
--- a/app/api/log-save/route.ts
+++ b/app/api/log-save/route.ts
@@ -1,6 +1,5 @@
-import { randomUUID } from "crypto"
import { z } from "zod"
-import { getLangfuseClient } from "@/lib/langfuse"
+import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
}
try {
- const timestamp = new Date().toISOString()
-
- // Find the most recent chat trace for this session to attach the save flag
- const tracesResponse = await langfuse.api.trace.list({
+ // Flag the session's latest chat trace as saved; a session without a
+ // trace (no chat yet) is skipped
+ const logged = await scoreLatestTrace(langfuse, {
sessionId,
- limit: 1,
+ name: "diagram-saved",
+ value: 1,
+ comment: `User saved diagram as ${filename}.${format}`,
})
- const traces = tracesResponse.data || []
- const latestTrace = traces[0]
-
- if (latestTrace) {
- // Add a score to the existing trace to flag that user saved
- await langfuse.api.ingestion.batch({
- batch: [
- {
- type: "score-create",
- id: randomUUID(),
- timestamp,
- body: {
- id: randomUUID(),
- traceId: latestTrace.id,
- name: "diagram-saved",
- value: 1,
- comment: `User saved diagram as ${filename}.${format}`,
- },
- },
- ],
- })
- }
- // If no trace found, skip logging (user hasn't chatted yet)
-
- return Response.json({ success: true, logged: !!latestTrace })
+ return Response.json({ success: true, logged })
} catch (error) {
console.error("Langfuse save error:", error)
return Response.json(
diff --git a/app/api/server-models/route.ts b/app/api/server-models/route.ts
index 49ea12bb..4f152f8b 100644
--- a/app/api/server-models/route.ts
+++ b/app/api/server-models/route.ts
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
- return NextResponse.json({
- models,
- hasConfig: models.length > 0,
- })
+ return NextResponse.json({ models })
}
diff --git a/app/api/validate-diagram/route.ts b/app/api/validate-diagram/route.ts
index 3fba6628..c91e6377 100644
--- a/app/api/validate-diagram/route.ts
+++ b/app/api/validate-diagram/route.ts
@@ -1,9 +1,9 @@
/**
* API endpoint for VLM-based diagram validation.
- * Accepts a PNG image and streams validation results using useObject-compatible format.
+ * Accepts a PNG image and answers with the validation result as JSON.
*/
-import { Output, streamText } from "ai"
+import { generateText, Output } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import {
@@ -35,13 +35,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
-/** A fixed result in the text format useObject reads */
-function createStreamingResponse(result: ValidationResult): Response {
- return new Response(JSON.stringify(result), {
- headers: { "Content-Type": "text/plain; charset=utf-8" },
- })
-}
-
export async function POST(req: Request): Promise {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
@@ -53,7 +46,7 @@ export async function POST(req: Request): Promise {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) {
- return createStreamingResponse(DEFAULT_VALID_RESULT)
+ return Response.json(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
@@ -123,7 +116,7 @@ export async function POST(req: Request): Promise {
error,
)
// Return valid if no vision model is configured
- return createStreamingResponse(DEFAULT_VALID_RESULT)
+ return Response.json(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
@@ -133,8 +126,7 @@ export async function POST(req: Request): Promise {
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
- // Stream the VLM response for useObject consumption
- const result = streamText({
+ const { output, totalUsage } = await generateText({
model,
output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT,
@@ -155,23 +147,19 @@ export async function POST(req: Request): Promise {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
- onFinish: ({ output, totalUsage }) => {
- if (countsQuota && totalUsage) {
- recordTokenUsage(
- userId,
- (totalUsage.inputTokens || 0) +
- (totalUsage.outputTokens || 0),
- )
- }
- if (sessionId && output) {
- console.log(
- `[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
- )
- }
- },
})
-
- return result.toTextStreamResponse()
+ if (countsQuota) {
+ await recordTokenUsage(
+ userId,
+ (totalUsage.inputTokens || 0) + (totalUsage.outputTokens || 0),
+ )
+ }
+ if (sessionId) {
+ console.log(
+ `[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
+ )
+ }
+ return Response.json(output)
} catch (error) {
// Log with session context if available
const errorMessage =
@@ -179,6 +167,6 @@ export async function POST(req: Request): Promise {
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
- return createStreamingResponse(DEFAULT_VALID_RESULT)
+ return Response.json(DEFAULT_VALID_RESULT)
}
}
diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts
index 7eac4c1a..3df9fb8c 100644
--- a/app/api/validate-model/route.ts
+++ b/app/api/validate-model/route.ts
@@ -1,249 +1,29 @@
-import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
-import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
-import { checkAdminAuth } from "@/lib/admin/auth"
import {
- 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 { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
-import { getUserIdFromRequest } from "@/lib/user-id"
+ type TestModelRequest,
+ testModelConnection,
+} from "@/lib/test-model-connection"
export const runtime = "nodejs"
-interface ValidateRequest {
- provider: ProviderName
- apiKey: string
- baseUrl?: string
- modelId: string
- // AWS Bedrock specific
- 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
_BASE_URL
- serverBaseUrl?: boolean
-}
-
-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."
-
+// The Settings dialog's Test button: a model on the user's own credentials
export async function POST(req: Request) {
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)
+ // Lets the server send requests to arbitrary URLs, so require the access code
const accessError = checkAccessCode(req)
- if (accessError && checkAdminAuth(req)) return accessError
+ if (accessError) return accessError
+ let body: TestModelRequest
try {
- const body: ValidateRequest = await req.json()
- const {
- provider,
- apiKey,
- modelId,
- awsAccessKeyId,
- awsSecretAccessKey,
- awsRegion,
- awsSessionToken,
- // Note: Express Mode only needs vertexApiKey
- vertexApiKey,
- } = body
-
- if (!provider || !modelId) {
- return NextResponse.json(
- { valid: false, error: "Provider and model ID are required" },
- { 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
_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 &&
- !serverUrl &&
- !allowPrivateUrls() &&
- (await isPrivateUrl(baseUrl))
- ) {
- return NextResponse.json(
- { valid: false, error: "Invalid base URL" },
- { status: 400 },
- )
- }
-
- // Validate credentials based on provider
- if (provider === "bedrock") {
- if (
- !(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
- !awsRegion
- ) {
- return NextResponse.json(
- {
- valid: false,
- error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
- },
- { status: 400 },
- )
- }
- } else if (provider === "vertexai") {
- if (!vertexApiKey) {
- return NextResponse.json(
- {
- valid: false,
- error: "Vertex AI API key is required for Express Mode",
- },
- { status: 400 },
- )
- }
- } else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
- return NextResponse.json(
- { valid: false, error: "API key is required" },
- { 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 },
- )
- }
-
- // 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)
-
- const { code, message } = classifyLLMError(error)
+ body = await req.json()
+ } catch {
return NextResponse.json(
- { valid: false, code, error: message },
- { status: 200 }, // Return 200 so client can read error message
+ { valid: false, error: "Invalid JSON body" },
+ { status: 400 },
)
}
+ // A URL in the request is one the user typed, never the server's own
+ return testModelConnection(req, body, false)
}
diff --git a/app/globals.css b/app/globals.css
index c4df4ee3..6cf34e3f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -98,9 +98,7 @@
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
- --duration-normal: 200ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
- --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/*
@@ -241,13 +239,6 @@
.shadow-pop {
box-shadow: var(--shadow-pop);
}
- /* Older names kept for components that still use them */
- .shadow-soft {
- box-shadow: var(--shadow-float);
- }
- .shadow-dialog {
- box-shadow: var(--shadow-pop);
- }
}
@keyframes fadeIn {
@@ -284,17 +275,13 @@
}
}
-@keyframes check-pop {
- 0% {
- transform: scale(0.8);
- opacity: 0;
+/* A highlight sweeps across "Thinking..." text (Shimmer) */
+@keyframes shimmer {
+ from {
+ background-position: 100% center;
}
- 50% {
- transform: scale(1.1);
- }
- 100% {
- transform: scale(1);
- opacity: 1;
+ to {
+ background-position: 0% center;
}
}
@@ -307,15 +294,15 @@
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
-.animate-check-pop {
- animation: check-pop 0.25s var(--ease-spring) forwards;
+.animate-shimmer {
+ animation: shimmer var(--shimmer-duration, 2s) linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-message-in,
.animate-panel-in,
- .animate-check-pop {
+ .animate-shimmer {
animation: none;
}
}
diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx
deleted file mode 100644
index 7164f44b..00000000
--- a/components/ai-elements/model-selector.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-import { Cloud } from "lucide-react"
-import type { ComponentProps, ElementRef, ReactNode } from "react"
-import { useEffect, useRef, useState } from "react"
-import {
- Command,
- CommandDialog,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
- CommandShortcut,
-} from "@/components/ui/command"
-import {
- Dialog,
- DialogContent,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import { cn } from "@/lib/utils"
-
-export type ModelSelectorProps = ComponentProps
-
-export const ModelSelector = (props: ModelSelectorProps) => (
-
-)
-
-export type ModelSelectorTriggerProps = ComponentProps
-
-export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
-
-)
-
-export type ModelSelectorContentProps = ComponentProps & {
- title?: ReactNode
-}
-
-export const ModelSelectorContent = ({
- className,
- children,
- title = "Model Selector",
- ...props
-}: ModelSelectorContentProps) => (
-
- {title}
-
- {children}
-
-
-)
-
-export type ModelSelectorDialogProps = ComponentProps
-
-export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
-
-)
-
-export type ModelSelectorInputProps = ComponentProps
-
-export const ModelSelectorInput = ({
- className,
- ...props
-}: ModelSelectorInputProps) => (
-
-)
-
-export type ModelSelectorListProps = ComponentProps
-
-export const ModelSelectorList = ({
- className,
- ...props
-}: ModelSelectorListProps) => {
- const listRef = useRef>(null)
- const [showShadow, setShowShadow] = useState(false)
-
- useEffect(() => {
- const listElement = listRef.current
- if (!listElement) return
-
- const checkScroll = () => {
- const { scrollTop, scrollHeight, clientHeight } = listElement
- // Show shadow if there is more content below
- // Using a small threshold to handle fractional pixel rendering
- setShowShadow(
- scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
- )
- }
-
- // Initial check
- checkScroll()
-
- // Event listeners
- listElement.addEventListener("scroll", checkScroll)
- window.addEventListener("resize", checkScroll)
-
- // Observe content changes (e.g. async loading of items)
- const observer = new MutationObserver(checkScroll)
- observer.observe(listElement, { childList: true, subtree: true })
-
- return () => {
- listElement.removeEventListener("scroll", checkScroll)
- window.removeEventListener("resize", checkScroll)
- observer.disconnect()
- }
- }, [])
-
- return (
-