Compare commits

..

2 Commits

Author SHA1 Message Date
dayuan.jiang
1d3a498306 fix: improve Kimi K2.5 image support logic and add tests
- Only block kimi-k2 specifically, not all Kimi models
- Add unit test for kimi-k2.5 image support
2026-02-02 00:05:50 +09:00
dayuan.jiang
2712f6d67c fix: enable image support for Kimi K2.5 model
Kimi K2.5 supports image input but was incorrectly blocked by the
supportsImageInput check that excluded all Kimi models without
"vision" in the name. Updated the condition to only exclude the
older K2 model while allowing K2.5.
2026-02-01 23:14:56 +09:00
18 changed files with 2664 additions and 1961 deletions

View File

@@ -186,7 +186,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string | string[]
apiKeyEnv?: string
baseUrlEnv?: string
provider?: string
} = {}
@@ -663,7 +663,7 @@ Available libraries:
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android, material_design
- UI/Mockups: android
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
@@ -708,7 +708,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
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`
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, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,

View File

@@ -1,6 +1,5 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import type { ComponentProps, ReactNode } from "react"
import {
Command,
CommandDialog,
@@ -70,62 +69,20 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
className,
...props
}: ModelSelectorListProps) => {
const listRef = useRef<ElementRef<typeof CommandList>>(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 (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
)
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -171,7 +171,6 @@ interface ChatInputProps {
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
@@ -196,7 +195,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
@@ -553,7 +551,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>

View File

@@ -1397,7 +1397,6 @@ export default function ChatPanel({
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
onConfigureModels={() => setShowModelConfigDialog(true)}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
shouldFocus={shouldFocusInput}
onFocused={() => setShouldFocusInput(false)}

View File

@@ -1661,16 +1661,12 @@ export function ModelConfigDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
id="show-unvalidated-models"
checked={modelConfig.showUnvalidatedModels}
onCheckedChange={
modelConfig.setShowUnvalidatedModels
}
/>
<Label
htmlFor="show-unvalidated-models"
className="text-xs text-muted-foreground cursor-pointer"
>
<Label className="text-xs text-muted-foreground cursor-pointer">
{dict.modelConfig.showUnvalidatedModels}
</Label>
</div>

View File

@@ -7,7 +7,6 @@ import {
ChevronDown,
Monitor,
Server,
Settings2,
User,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
@@ -34,7 +33,6 @@ interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure?: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
@@ -85,7 +83,6 @@ export function ModelSelector({
models,
selectedModelId,
onSelect,
onConfigure,
disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) {
@@ -212,241 +209,185 @@ export function ModelSelector({
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 &&
models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* Server Models Section */}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict
.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict
.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(
groupedUserModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict
.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
<div className="relative z-10 shrink-0 border-t bg-background">
{onConfigure && (
<div className="px-3 py-2">
<ModelSelectorItem
value="__configure_models__"
onSelect={() => {
onConfigure()
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</div>
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</div>
</div>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>

View File

@@ -77,13 +77,12 @@ function CommandInput({
)
}
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => {
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
{...props}
/>
)
})
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
}
function CommandEmpty({
...props

View File

@@ -1,367 +0,0 @@
# material_design
**Type:** SVG images (Google Material Icons CDN)
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
</mxCell>
```
Replace `{icon_name}` with any icon name from the list below.
## action (115)
- `account_balance`
- `account_balance_wallet`
- `account_box`
- `account_circle`
- `add_shopping_cart`
- `admin_panel_settings`
- `analytics`
- `arrow_right_alt`
- `article`
- `assessment`
- `assignment`
- `assignment_ind`
- `assignment_turned_in`
- `autorenew`
- `bookmark`
- `bookmark_border`
- `build`
- `calendar_month`
- `calendar_today`
- `card_giftcard`
- `check_circle`
- `check_circle_outline`
- `code`
- `contact_support`
- `credit_card`
- `dashboard`
- `date_range`
- `delete`
- `delete_forever`
- `delete_outline`
- `description`
- `dns`
- `done`
- `done_all`
- `done_outline`
- `drag_indicator`
- `event`
- `exit_to_app`
- `explore`
- `face`
- `fact_check`
- `favorite`
- `favorite_border`
- `feedback`
- `filter_alt`
- `fingerprint`
- `flight_takeoff`
- `grade`
- `help`
- `help_outline`
- `highlight_off`
- `history`
- `home`
- `info`
- `label`
- `language`
- `launch`
- `leaderboard`
- `lightbulb`
- `list`
- `lock`
- `lock_open`
- `login`
- `logout`
- `manage_accounts`
- `note_add`
- `open_in_full`
- `open_in_new`
- `paid`
- `payment`
- `pending`
- `pending_actions`
- `perm_identity`
- `pets`
- `power_settings_new`
- `preview`
- `print`
- `published_with_changes`
- `question_answer`
- `receipt`
- `reorder`
- `report_problem`
- `room`
- `savings`
- `schedule`
- `search`
- `settings`
- `shopping_bag`
- `shopping_basket`
- `shopping_cart`
- `star_rate`
- `stars`
- `store`
- `supervisor_account`
- `swap_horiz`
- `sync_alt`
- `task_alt`
- `thumb_up`
- `thumb_up_off_alt`
- `timeline`
- `tips_and_updates`
- `today`
- `touch_app`
- `trending_up`
- `update`
- `verified`
- `verified_user`
- `view_in_ar`
- `view_list`
- `visibility`
- `visibility_off`
- `watch_later`
- `work`
- `work_outline`
- `zoom_in`
## alert (4)
- `error`
- `error_outline`
- `warning`
- `warning_amber`
## av (12)
- `library_books`
- `mic`
- `pause`
- `play_arrow`
- `play_circle`
- `play_circle_filled`
- `play_circle_outline`
- `replay`
- `skip_next`
- `videocam`
- `volume_off`
- `volume_up`
## communication (13)
- `alternate_email`
- `business`
- `call`
- `chat`
- `chat_bubble_outline`
- `email`
- `forum`
- `list_alt`
- `location_on`
- `mail_outline`
- `phone`
- `qr_code_scanner`
- `vpn_key`
## content (27)
- `add`
- `add_box`
- `add_circle`
- `add_circle_outline`
- `block`
- `bolt`
- `calculate`
- `clear`
- `content_copy`
- `create`
- `filter_list`
- `flag`
- `how_to_reg`
- `insights`
- `inventory`
- `inventory_2`
- `link`
- `mail`
- `push_pin`
- `remove`
- `remove_circle`
- `remove_circle_outline`
- `reply`
- `save`
- `send`
- `sort`
- `undo`
## device (9)
- `dark_mode`
- `devices`
- `light_mode`
- `password`
- `restart_alt`
- `sell`
- `signal_cellular_alt`
- `summarize`
- `task`
## editor (9)
- `attach_file`
- `attach_money`
- `bar_chart`
- `checklist`
- `edit_note`
- `format_list_bulleted`
- `mode_edit`
- `monetization_on`
- `post_add`
## file (8)
- `cloud_upload`
- `download`
- `file_download`
- `file_upload`
- `folder`
- `folder_open`
- `grid_view`
- `upload_file`
## hardware (6)
- `computer`
- `keyboard_arrow_down`
- `keyboard_arrow_right`
- `phone_iphone`
- `security`
- `smartphone`
## image (16)
- `add_a_photo`
- `auto_awesome`
- `auto_stories`
- `circle`
- `collections`
- `edit`
- `image`
- `navigate_before`
- `navigate_next`
- `palette`
- `photo_camera`
- `picture_as_pdf`
- `receipt_long`
- `remove_red_eye`
- `timer`
- `tune`
## maps (11)
- `badge`
- `category`
- `directions_car`
- `local_fire_department`
- `local_offer`
- `local_shipping`
- `map`
- `menu_book`
- `place`
- `restaurant`
- `volunteer_activism`
## navigation (29)
- `apps`
- `arrow_back`
- `arrow_back_ios`
- `arrow_back_ios_new`
- `arrow_downward`
- `arrow_drop_down`
- `arrow_drop_up`
- `arrow_forward`
- `arrow_forward_ios`
- `arrow_right`
- `arrow_upward`
- `campaign`
- `cancel`
- `check`
- `chevron_left`
- `chevron_right`
- `close`
- `double_arrow`
- `east`
- `expand_less`
- `expand_more`
- `fullscreen`
- `menu`
- `menu_open`
- `more_horiz`
- `more_vert`
- `payments`
- `refresh`
- `unfold_more`
## notification (6)
- `account_tree`
- `event_available`
- `priority_high`
- `support_agent`
- `sync`
- `wifi`
## places (2)
- `apartment`
- `storefront`
## search (2)
- `feed`
- `manage_search`
## social (23)
- `construction`
- `emoji_emotions`
- `emoji_events`
- `engineering`
- `group`
- `group_add`
- `groups`
- `health_and_safety`
- `notifications`
- `notifications_active`
- `notifications_none`
- `people`
- `people_alt`
- `person`
- `person_add`
- `person_outline`
- `psychology`
- `public`
- `school`
- `share`
- `thumb_up_alt`
- `travel_explore`
- `water_drop`
## toggle (8)
- `check_box`
- `check_box_outline_blank`
- `radio_button_checked`
- `radio_button_unchecked`
- `star`
- `star_border`
- `star_outline`
- `toggle_on`
Total: 300 icons (top by popularity from 2100+ available)

View File

@@ -34,9 +34,8 @@ export interface ClientOverrides {
vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string>
// Custom env var name(s) for server models
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
}
@@ -100,12 +99,10 @@ export function resolveBaseURL(
/**
* Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
* When multiple keys are configured, randomly selects one for load balancing.
*
* Priority:
* 1. User-provided API key (overrides.apiKey)
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv)
* - If array, randomly picks one with a valid value
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* 3. Default provider env var (defaultEnvVar)
*/
function resolveApiKey(
@@ -113,30 +110,7 @@ function resolveApiKey(
defaultEnvVar: string,
): string | undefined {
if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) {
// Handle array of env var names - randomly select one
if (Array.isArray(overrides.apiKeyEnv)) {
// Filter to only env vars that have values
const validEnvVars = overrides.apiKeyEnv.filter(
(envVar) => process.env[envVar],
)
if (validEnvVars.length > 0) {
// Randomly select one
const selectedEnvVar =
validEnvVars[
Math.floor(Math.random() * validEnvVars.length)
]
console.log(
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
)
return process.env[selectedEnvVar]
}
} else {
return process.env[overrides.apiKeyEnv]
}
}
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
return process.env[defaultEnvVar]
}
@@ -542,24 +516,12 @@ function detectProvider(): ProviderName | null {
/**
* Validate that required API keys are present for the selected provider
* @param provider - The provider to validate
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
*/
function validateProviderCredentials(
provider: ProviderName,
customApiKeyEnv?: string | string[],
customApiKeyEnv?: string,
): void {
// Handle array of env var names - at least one must be set
if (Array.isArray(customApiKeyEnv)) {
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
if (!hasAnyKey) {
throw new Error(
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
`Please set at least one in your .env.local file.`,
)
}
return
}
// Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) {

View File

@@ -14,12 +14,9 @@ export const ServerProviderSchema = z.object({
name: z.string().min(1),
provider: ProviderNameSchema,
models: z.array(z.string().min(1)),
// Optional: custom environment variable name(s) for API key
// Can be a single string or array of strings for load balancing
// e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"]
apiKeyEnv: z
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
.optional(),
// Optional: custom environment variable name for API key
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
apiKeyEnv: z.string().min(1).optional(),
// Optional: custom environment variable name for base URL
baseUrlEnv: z.string().min(1).optional(),
// Optional: mark the first model in this provider as the default
@@ -39,9 +36,8 @@ export interface FlattenedServerModel {
provider: ProviderName
providerLabel: string
isDefault: boolean
// Custom env var name(s) for API key (optional)
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
// Custom env var names for credentials (optional)
apiKeyEnv?: string
baseUrlEnv?: string
}

View File

@@ -51,9 +51,9 @@ parameters: {
}
---Tool4---
tool name: get_shape_library
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
}
---End of tools---
@@ -61,7 +61,7 @@ IMPORTANT: Choose the right tool:
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams
@@ -92,7 +92,7 @@ Note that:
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
- Return XML only via tool calls, never in text responses.
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool:

View File

@@ -73,9 +73,8 @@ export interface FlattenedModel {
source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean
// Custom env var name(s) for server models
// Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
// Custom env var names for server models (allows multiple API keys per provider)
apiKeyEnv?: string
baseUrlEnv?: string
}

3419
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@
"@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.16.1",
"@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
@@ -129,7 +129,7 @@
"electron-builder": "^26.0.12",
"esbuild": "^0.27.2",
"eslint": "9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.1.1",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",

View File

@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.16",
"version": "0.1.15",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",

View File

@@ -69,8 +69,6 @@ interface SessionState {
lastUpdated: Date
svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
exportData?: string // Base64/SVG data returned by browser after export
}
export const stateStore = new Map<string, SessionState>()
@@ -93,8 +91,6 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
lastUpdated: new Date(),
svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request
exportData: existing?.exportData, // Preserve export result
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion
@@ -259,7 +255,6 @@ function handleStateApi(
xml: state?.xml || null,
version: state?.version || 0,
syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
}),
)
} else if (req.method === "POST") {
@@ -269,30 +264,13 @@ function handleStateApi(
})
req.on("end", () => {
try {
const data = JSON.parse(body)
const { sessionId } = data
const { sessionId, xml, svg } = JSON.parse(body)
if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
return
}
// Browser is returning export data (png/svg)
if (data.exportData !== undefined) {
const state = stateStore.get(sessionId)
if (state) {
state.exportData = data.exportData
state.exportFormat = undefined
log.debug(
`Export data received for session=${sessionId}`,
)
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
return
}
const version = setState(sessionId, data.xml, data.svg)
const version = setState(sessionId, xml, svg)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
@@ -660,7 +638,6 @@ function getHtmlPage(sessionId: string): string {
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null;
let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -676,23 +653,6 @@ function getHtmlPage(sessionId: string): string {
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) {
// Handle MCP server export request (png/svg)
// Verify the response matches the requested format to avoid capturing
// unrelated exports (autosave SVG, sync XML)
if (pendingMcpExport) {
const d = msg.data;
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
if (isPng || isSvg) {
pendingMcpExport = null;
fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d })
}).catch(() => {});
return;
}
}
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload;
@@ -772,21 +732,11 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
}
// Load new diagram from server (before export, so we export latest)
// Load new diagram from server
if (s.version > currentVersion && s.xml) {
currentVersion = s.version;
loadDiagram(s.xml, true);
}
// Handle export request from MCP server (png/svg) - after version update
if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat;
const exportOpts = s.exportFormat === 'png'
? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
// Timeout: reset if draw.io never responds
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
}
} catch {}
}

View File

@@ -546,24 +546,16 @@ server.registerTool(
server.registerTool(
"export_diagram",
{
description:
"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " +
"The format is auto-detected from the file extension, or can be specified explicitly.",
description: "Export the current diagram to a .drawio file.",
inputSchema: {
path: z
.string()
.describe(
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)",
),
format: z
.enum(["drawio", "png", "svg"])
.optional()
.describe(
"Export format. If omitted, detected from file extension. Defaults to drawio.",
"File path to save the diagram (e.g., ./diagram.drawio)",
),
},
},
async ({ path, format }) => {
async ({ path }) => {
try {
if (!currentSession) {
return {
@@ -598,107 +590,21 @@ server.registerTool(
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
// Detect format from extension if not specified
const ext = nodePath.extname(path).toLowerCase()
const detectedFormat =
format ||
(ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio")
// Original .drawio export path (unchanged logic)
if (detectedFormat === "drawio") {
let filePath = path
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`)
return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
}
}
// PNG or SVG: request browser to export via iframe
let filePath = path
if (ext !== `.${detectedFormat}`) {
if (ext === ".drawio" || ext === ".png" || ext === ".svg") {
filePath = filePath.slice(0, -ext.length)
}
filePath = `${filePath}.${detectedFormat}`
if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio`
}
const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
const state = getState(currentSession.id)
if (!state) {
return {
content: [
{
type: "text",
text: "Error: Session state not found. Is the browser open?",
},
],
isError: true,
}
}
state.exportFormat = detectedFormat as "png" | "svg"
state.exportData = undefined
log.info(`Diagram exported to ${absolutePath}`)
// Wait for browser to produce the export data
const timeoutMs = 10000
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (state.exportData) break
await new Promise((r) => setTimeout(r, 200))
}
const exportData = state.exportData as string | undefined
state.exportData = undefined
state.exportFormat = undefined
if (!exportData) {
return {
content: [
{
type: "text",
text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.",
},
],
isError: true,
}
}
// Decode and write
if (detectedFormat === "png") {
const base64 = exportData.replace(
/^data:image\/png;base64,/,
"",
)
await fs.writeFile(absolutePath, Buffer.from(base64, "base64"))
} else {
let svgContent = exportData
if (svgContent.startsWith("data:image/svg+xml;base64,")) {
const base64 = svgContent.replace(
/^data:image\/svg\+xml;base64,/,
"",
)
svgContent = Buffer.from(base64, "base64").toString("utf-8")
}
await fs.writeFile(absolutePath, svgContent, "utf-8")
}
const stat = await fs.stat(absolutePath)
log.info(
`Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,
)
return {
content: [
{
type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`,
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
},
],
}

View File

@@ -45,72 +45,6 @@ describe("ServerModelsConfigSchema", () => {
ServerModelsConfigSchema.parse(invalidConfig as any),
).toThrow()
})
it("accepts apiKeyEnv as single string", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: "OPENAI_API_KEY_TEAM_A",
},
],
}
const parsed = ServerModelsConfigSchema.parse(config)
expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A")
})
it("accepts apiKeyEnv as array of strings for load balancing", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"],
},
],
}
const parsed = ServerModelsConfigSchema.parse(config)
expect(parsed.providers[0].apiKeyEnv).toEqual([
"OPENAI_KEY_1",
"OPENAI_KEY_2",
"OPENAI_KEY_3",
])
})
it("rejects empty array for apiKeyEnv", () => {
const config = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: [],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
})
it("rejects empty string in apiKeyEnv array", () => {
const config = {
providers: [
{
name: "OpenAI Server",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["VALID_KEY", ""],
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
})
})
describe("loadFlattenedServerModels", () => {
@@ -148,24 +82,4 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.provider).toBe("openai")
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
})
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
const config: ServerModelsConfig = {
providers: [
{
name: "OpenAI LoadBalanced",
provider: "openai",
models: ["gpt-4o"],
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"],
},
],
}
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
const models = await loadFlattenedServerModels()
expect(models.length).toBe(1)
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
})
})