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

View File

@@ -5,7 +5,6 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
@@ -35,31 +34,6 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
try {
const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT },
signal: headController.signal,
})
const contentType = headResponse.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
},
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
// Extract article content with timeout to avoid tying up server resources // Extract article content with timeout to avoid tying up server resources
const controller = new AbortController() const controller = new AbortController()
@@ -70,7 +44,9 @@ export async function POST(req: Request) {
let article let article
try { try {
article = await extract(url, undefined, { article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT }, headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal, signal: controller.signal,
}) })
} catch (err: any) { } catch (err: any) {

View File

@@ -174,21 +174,10 @@ export async function POST(req: Request) {
} }
case "ollama": { case "ollama": {
// SECURITY: Mirror ai-providers.ts guard — only use server const ollama = createOllama({
// OLLAMA_API_KEY when the URL is also from server config. baseURL: baseUrl || "http://localhost:11434",
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",
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
}) })
model = ollamaProvider(modelId) model = ollama(modelId)
break break
} }

View File

@@ -1,6 +1,5 @@
import { Cloud } from "lucide-react" import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react" import type { ComponentProps, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import { import {
Command, Command,
CommandDialog, CommandDialog,
@@ -70,45 +69,9 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({ export const ModelSelectorList = ({
className, className,
...props ...props
}: ModelSelectorListProps) => { }: 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"> <div className="relative">
<CommandList <CommandList
ref={listRef}
className={cn( className={cn(
// Hide scrollbar on all platforms // Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
@@ -117,15 +80,9 @@ export const ModelSelectorList = ({
{...props} {...props}
/> />
{/* Bottom shadow indicator for scrollable content */} {/* Bottom shadow indicator for scrollable content */}
<div <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" />
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> </div>
) )
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty> export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

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

View File

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

View File

@@ -282,7 +282,6 @@ export function ModelConfigDialog({
// Check credentials based on provider type // Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock" const isBedrock = selectedProvider.provider === "bedrock"
const isEdgeOne = selectedProvider.provider === "edgeone" const isEdgeOne = selectedProvider.provider === "edgeone"
const isOllama = selectedProvider.provider === "ollama"
const isVertexAI = selectedProvider.provider === "vertexai" const isVertexAI = selectedProvider.provider === "vertexai"
if (isBedrock) { if (isBedrock) {
if ( if (
@@ -297,7 +296,7 @@ export function ModelConfigDialog({
if (!selectedProvider.vertexApiKey) { if (!selectedProvider.vertexApiKey) {
return return
} }
} else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) { } else if (!isEdgeOne && !selectedProvider.apiKey) {
return return
} }
@@ -1031,6 +1030,8 @@ export function ModelConfigDialog({
</div> </div>
</> </>
) : selectedProvider.provider === ) : selectedProvider.provider ===
"ollama" ||
selectedProvider.provider ===
"edgeone" ? ( "edgeone" ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1099,9 +1100,6 @@ export function ModelConfigDialog({
dict.modelConfig dict.modelConfig
.apiKey .apiKey
} }
{selectedProvider.provider ===
"ollama" &&
` ${dict.modelConfig.optional}`}
</Label> </Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -1165,9 +1163,7 @@ export function ModelConfigDialog({
handleValidate handleValidate
} }
disabled={ disabled={
(selectedProvider.provider !== !selectedProvider.apiKey ||
"ollama" &&
!selectedProvider.apiKey) ||
validationStatus === validationStatus ===
"validating" "validating"
} }
@@ -1665,16 +1661,12 @@ export function ModelConfigDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="show-unvalidated-models"
checked={modelConfig.showUnvalidatedModels} checked={modelConfig.showUnvalidatedModels}
onCheckedChange={ onCheckedChange={
modelConfig.setShowUnvalidatedModels modelConfig.setShowUnvalidatedModels
} }
/> />
<Label <Label className="text-xs text-muted-foreground cursor-pointer">
htmlFor="show-unvalidated-models"
className="text-xs text-muted-foreground cursor-pointer"
>
{dict.modelConfig.showUnvalidatedModels} {dict.modelConfig.showUnvalidatedModels}
</Label> </Label>
</div> </div>

View File

@@ -7,7 +7,6 @@ import {
ChevronDown, ChevronDown,
Monitor, Monitor,
Server, Server,
Settings2,
User, User,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
@@ -34,7 +33,6 @@ interface ModelSelectorProps {
models: FlattenedModel[] models: FlattenedModel[]
selectedModelId: string | undefined selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure?: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean showUnvalidatedModels?: boolean
} }
@@ -85,7 +83,6 @@ export function ModelSelector({
models, models,
selectedModelId, selectedModelId,
onSelect, onSelect,
onConfigure,
disabled = false, disabled = false,
showUnvalidatedModels = false, showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
@@ -212,12 +209,9 @@ export function ModelSelector({
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} 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]"> <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty> <ModelSelectorEmpty>
{displayModels.length === 0 && {displayModels.length === 0 && models.length > 0
models.length > 0
? dict.modelConfig.noVerifiedModels ? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound} : dict.modelConfig.noModelsFound}
</ModelSelectorEmpty> </ModelSelectorEmpty>
@@ -256,36 +250,24 @@ export function ModelSelector({
<> <>
<ModelSelectorSectionHeader <ModelSelectorSectionHeader
icon={<Monitor />} icon={<Monitor />}
label={ label={dict.modelConfig.serverModels}
dict.modelConfig.serverModels
}
/> />
{Array.from( {Array.from(groupedServerModels.entries()).map(
groupedServerModels.entries(),
).map(
([ ([
providerLabel, providerLabel,
{ { provider, models: providerModels },
provider,
models: providerModels,
},
]) => ( ]) => (
<ModelSelectorGroup <ModelSelectorGroup
key={`server-${providerLabel}`} key={`server-${providerLabel}`}
heading={providerLabel} heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4" className="[&>[cmdk-group-heading]]:pl-4"
> >
{providerModels.map( {providerModels.map((model) => (
(model) => (
<ModelSelectorItem <ModelSelectorItem
key={model.id} key={model.id}
value={ value={model.modelId}
model.modelId
}
onSelect={() => onSelect={() =>
handleSelect( handleSelect(model.id)
model.id,
)
} }
className="cursor-pointer" className="cursor-pointer"
> >
@@ -302,35 +284,29 @@ export function ModelSelector({
provider={ provider={
PROVIDER_LOGO_MAP[ PROVIDER_LOGO_MAP[
provider provider
] || ] || provider
provider
} }
className="mr-2" className="mr-2"
/> />
<ModelSelectorName> <ModelSelectorName>
{ {model.modelId}
model.modelId
}
</ModelSelectorName> </ModelSelectorName>
{model.isDefault && ( {model.isDefault && (
<span <span
title={ title={
dict dict.modelConfig
.modelConfig
.serverDefaultModel .serverDefaultModel
} }
className="ml-auto text-xs text-muted-foreground" className="ml-auto text-xs text-muted-foreground"
> >
{ {
dict dict.modelConfig
.modelConfig
.default .default
} }
</span> </span>
)} )}
</ModelSelectorItem> </ModelSelectorItem>
), ))}
)}
</ModelSelectorGroup> </ModelSelectorGroup>
), ),
)} )}
@@ -347,32 +323,22 @@ export function ModelSelector({
icon={<User />} icon={<User />}
label={dict.modelConfig.userModels} label={dict.modelConfig.userModels}
/> />
{Array.from( {Array.from(groupedUserModels.entries()).map(
groupedUserModels.entries(),
).map(
([ ([
providerLabel, providerLabel,
{ { provider, models: providerModels },
provider,
models: providerModels,
},
]) => ( ]) => (
<ModelSelectorGroup <ModelSelectorGroup
key={`user-${providerLabel}`} key={`user-${providerLabel}`}
heading={providerLabel} heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4" className="[&>[cmdk-group-heading]]:pl-4"
> >
{providerModels.map( {providerModels.map((model) => (
(model) => (
<ModelSelectorItem <ModelSelectorItem
key={model.id} key={model.id}
value={ value={model.modelId}
model.modelId
}
onSelect={() => onSelect={() =>
handleSelect( handleSelect(model.id)
model.id,
)
} }
className="cursor-pointer" className="cursor-pointer"
> >
@@ -389,22 +355,18 @@ export function ModelSelector({
provider={ provider={
PROVIDER_LOGO_MAP[ PROVIDER_LOGO_MAP[
provider provider
] || ] || provider
provider
} }
className="mr-2" className="mr-2"
/> />
<ModelSelectorName> <ModelSelectorName>
{ {model.modelId}
model.modelId
}
</ModelSelectorName> </ModelSelectorName>
{model.validated !== {model.validated !==
true && ( true && (
<span <span
title={ title={
dict dict.modelConfig
.modelConfig
.unvalidatedModelWarning .unvalidatedModelWarning
} }
> >
@@ -412,41 +374,20 @@ export function ModelSelector({
</span> </span>
)} )}
</ModelSelectorItem> </ModelSelectorItem>
), ))}
)}
</ModelSelectorGroup> </ModelSelectorGroup>
), ),
)} )}
</> </>
)} )}
</ModelSelectorList>
</div> {/* Info text */}
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */} <div className="px-3 py-2 text-xs text-muted-foreground border-t">
<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 {showUnvalidatedModels
? dict.modelConfig.allModelsShown ? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown} : dict.modelConfig.onlyVerifiedShown}
</div> </div>
</div> </ModelSelectorList>
</div>
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelectorRoot> </ModelSelectorRoot>
</div> </div>

View File

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

@@ -359,9 +359,9 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
baseUrl: "MODELSCOPE_BASE_URL", baseUrl: "MODELSCOPE_BASE_URL",
}, },
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" }, gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock doesn't use API keys in the same way // bedrock and ollama don't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" }, bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" }, ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
} }
/** /**

View File

@@ -94,8 +94,7 @@ if (!gotTheLock) {
if ( if (
url.includes("diagrams.net") || url.includes("diagrams.net") ||
url.includes("draw.io") || url.includes("draw.io") ||
url.startsWith("http://localhost") || url.startsWith("http://localhost")
url.startsWith("http://127.0.0.1")
) { ) {
return { action: "allow" } return { action: "allow" }
} }

View File

@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
const env: Record<string, string> = { const env: Record<string, string> = {
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", HOSTNAME: "localhost",
// Enable Node.js built-in proxy support for fetch (Node.js 24+) // Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1", NODE_USE_ENV_PROXY: "1",
} }

View File

@@ -9,11 +9,9 @@ import { app } from "electron"
const PORT_CONFIG = { const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility // Development mode uses fixed port for hot reload compatibility
development: 6002, development: 6002,
// Legacy production port — tried first to preserve localStorage for existing users // Production mode uses fixed port (61337) to preserve localStorage
legacyProduction: 61337, // Falls back to sequential ports if unavailable
// New production port below the ephemeral range (49152-65535) production: 61337,
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
production: 13370,
// Maximum attempts to find an available port (fallback) // Maximum attempts to find an available port (fallback)
maxAttempts: 100, maxAttempts: 100,
} }
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
export function isPortAvailable(port: number): Promise<boolean> { export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer() const server = net.createServer()
server.once("error", (err: NodeJS.ErrnoException) => { server.once("error", () => resolve(false))
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("listening", () => { server.once("listening", () => {
server.close() server.close()
resolve(true) resolve(true)
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: uses fixed port (13370) to preserve localStorage * - In production: uses fixed port (61337) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable * - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
* *
* @param reuseExisting If true, try to reuse the previously allocated port * @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port * @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const isDev = !app.isPackaged
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// In production, try legacy port first to preserve existing users' localStorage // Try preferred port first
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) { if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort allocatedPort = preferredPort
return preferredPort return preferredPort
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
} }
} }
// Last resort: let the OS pick an available port throw new Error(
console.warn( `Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
) )
const osPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.once("listening", () => {
const addr = server.address()
const port = (addr as net.AddressInfo).port
server.close(() => resolve(port))
})
server.listen(0, "127.0.0.1")
})
allocatedPort = osPort
console.log(`OS assigned port: ${osPort}`)
return osPort
} }
/** /**
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.", "No port allocated yet. Call findAvailablePort() first.",
) )
} }
return `http://127.0.0.1:${allocatedPort}` return `http://localhost:${allocatedPort}`
} }

View File

@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
// Handle page title updates // Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => { mainWindow.webContents.on("page-title-updated", (event, title) => {
if ( if (title && !title.includes("localhost")) {
title &&
!title.includes("localhost") &&
!title.includes("127.0.0.1")
) {
mainWindow?.setTitle(title) mainWindow?.setTitle(title)
} else { } else {
event.preventDefault() event.preventDefault()

View File

@@ -59,9 +59,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high) # AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
# AZURE_REASONING_SUMMARY=detailed # AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud) # Ollama (Local) Configuration
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud # OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
# 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) # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
# OpenRouter Configuration # OpenRouter Configuration

View File

@@ -34,9 +34,8 @@ export interface ClientOverrides {
vertexApiKey?: string | null // Express Mode API key vertexApiKey?: string | null // Express Mode API key
// Custom headers (e.g., for EdgeOne cookie auth) // Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string> headers?: Record<string, string>
// Custom env var name(s) for server models // Custom env var names for server models (allows multiple API keys per provider)
// Can be a single string or array of strings for load balancing apiKeyEnv?: string
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
@@ -100,12 +99,10 @@ export function resolveBaseURL(
/** /**
* Resolve API key from custom env var name or default env var. * Resolve API key from custom env var name or default env var.
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config. * Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
* When multiple keys are configured, randomly selects one for load balancing.
* *
* Priority: * Priority:
* 1. User-provided API key (overrides.apiKey) * 1. User-provided API key (overrides.apiKey)
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv) * 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
* - If array, randomly picks one with a valid value
* 3. Default provider env var (defaultEnvVar) * 3. Default provider env var (defaultEnvVar)
*/ */
function resolveApiKey( function resolveApiKey(
@@ -113,30 +110,7 @@ function resolveApiKey(
defaultEnvVar: string, defaultEnvVar: string,
): string | undefined { ): string | undefined {
if (overrides?.apiKey) return overrides.apiKey if (overrides?.apiKey) return overrides.apiKey
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
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]
}
}
return process.env[defaultEnvVar] return process.env[defaultEnvVar]
} }
@@ -542,24 +516,12 @@ function detectProvider(): ProviderName | null {
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
* @param provider - The provider to validate * @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( function validateProviderCredentials(
provider: ProviderName, provider: ProviderName,
customApiKeyEnv?: string | string[], customApiKeyEnv?: string,
): void { ): 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 // Use custom env var name if provided, otherwise use default
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider] const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
@@ -596,7 +558,7 @@ function validateProviderCredentials(
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to http://localhost:11434)
* - OPENROUTER_API_KEY: OpenRouter API key * - OPENROUTER_API_KEY: OpenRouter API key
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
@@ -611,15 +573,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided. // If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints. // This prevents attackers from redirecting server API keys to malicious endpoints.
// Exception: EdgeOne doesn't require API keys. // Exception: EdgeOne and Ollama providers don't require API keys
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
if ( if (
overrides?.baseUrl && overrides?.baseUrl &&
!overrides?.apiKey && !overrides?.apiKey &&
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
overrides?.provider !== "edgeone" && overrides?.provider !== "edgeone" &&
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY) overrides?.provider !== "ollama"
) { ) {
throw new Error( throw new Error(
`API key is required when using a custom base URL. ` + `API key is required when using a custom base URL. ` +
@@ -880,19 +840,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "ollama": { case "ollama": {
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
// SECURITY: When client provides a custom base URL, only use if (baseURL) {
// client-provided API key. Never fall back to server OLLAMA_API_KEY const customOllama = createOllama({ baseURL })
// to prevent leaking server credentials to user-controlled endpoints.
const apiKey = overrides?.baseUrl
? overrides?.apiKey || undefined
: resolveApiKey(overrides, "OLLAMA_API_KEY")
if (baseURL || apiKey) {
const customOllama = createOllama({
...(baseURL && { baseURL }),
...(apiKey && {
headers: { Authorization: `Bearer ${apiKey}` },
}),
})
model = customOllama(modelId) model = customOllama(modelId)
} else { } else {
model = ollama(modelId) model = ollama(modelId)
@@ -1230,12 +1179,7 @@ export function supportsImageInput(modelId: string): boolean {
} }
// Qwen text models (not vision variants like qwen-vl) // Qwen text models (not vision variants like qwen-vl)
// qwen3.5-plus is a vision model if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5-plus")
) {
return false return false
} }

View File

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

View File

@@ -51,9 +51,9 @@ parameters: {
} }
---Tool4--- ---Tool4---
tool name: get_shape_library 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: { 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--- ---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 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 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 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: Core capabilities:
- Generate valid, well-formed XML strings for draw.io diagrams - 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. - 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. - 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. - 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. - NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
When using edit_diagram tool: When using edit_diagram tool:

View File

@@ -73,9 +73,8 @@ export interface FlattenedModel {
source?: "user" | "server" source?: "user" | "server"
// Whether this model is the server default (matches AI_MODEL env var) // Whether this model is the server default (matches AI_MODEL env var)
isDefault?: boolean isDefault?: boolean
// Custom env var name(s) for server models // Custom env var names for server models (allows multiple API keys per provider)
// Can be a single string or array of strings for load balancing apiKeyEnv?: string
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
@@ -104,7 +103,7 @@ export const PROVIDER_INFO: Record<
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: { ollama: {
label: "Ollama", label: "Ollama",
defaultBaseUrl: "https://ollama.com/api", defaultBaseUrl: "http://localhost:11434",
}, },
openrouter: { openrouter: {
label: "OpenRouter", label: "OpenRouter",
@@ -264,7 +263,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen/Qwen2.5-Coder-32B-Instruct",
"Qwen/Qwen2.5-7B-Instruct", "Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct", "Qwen/Qwen2-VL-72B-Instruct",
"qwen3.5-plus",
], ],
sglang: [ sglang: [
// SGLang is OpenAI-compatible, models depend on deployment // SGLang is OpenAI-compatible, models depend on deployment
@@ -294,7 +292,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-32B", "Qwen/Qwen3-32B",
"qwen3.5-plus",
// DeepSeek // DeepSeek
"deepseek-ai/DeepSeek-R1-0528", "deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2", "deepseek-ai/DeepSeek-V3.2",

4666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,15 +43,15 @@
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18", "@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.8.0", "@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.16.1", "@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -72,7 +72,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.563.0", "lucide-react": "^0.562.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
@@ -129,7 +129,7 @@
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.16", "version": "0.1.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.16", "version": "0.1.12",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
@@ -469,9 +469,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.9", "version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -481,12 +481,12 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0", "version": "1.25.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.7",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -494,15 +494,14 @@
"cross-spawn": "^7.0.5", "cross-spawn": "^7.0.5",
"eventsource": "^3.0.2", "eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0", "eventsource-parser": "^3.0.0",
"express": "^5.2.1", "express": "^5.0.1",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^7.5.0",
"hono": "^4.11.4", "jose": "^6.1.1",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2", "json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0", "pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0", "zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1" "zod-to-json-schema": "^3.25.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -521,9 +520,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.12", "version": "24.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
"integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==", "integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1075,13 +1074,10 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.2.1", "version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@@ -1264,9 +1260,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.9", "version": "4.11.1",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -1352,15 +1348,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2064,9 +2051,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
@@ -2074,9 +2061,9 @@
} }
}, },
"node_modules/zod-to-json-schema": { "node_modules/zod-to-json-schema": {
"version": "3.25.1", "version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"zod": "^3.25 || ^4" "zod": "^3.25 || ^4"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "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", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -69,8 +69,6 @@ interface SessionState {
lastUpdated: Date lastUpdated: Date
svg?: string // Cached SVG from last browser save svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds 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>() export const stateStore = new Map<string, SessionState>()
@@ -93,8 +91,6 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
lastUpdated: new Date(), lastUpdated: new Date(),
svg: svg || existing?.svg, // Preserve cached SVG if not provided svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state 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}`) log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion return newVersion
@@ -259,7 +255,6 @@ function handleStateApi(
xml: state?.xml || null, xml: state?.xml || null,
version: state?.version || 0, version: state?.version || 0,
syncRequested: !!state?.syncRequested, syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
@@ -269,30 +264,13 @@ function handleStateApi(
}) })
req.on("end", () => { req.on("end", () => {
try { try {
const data = JSON.parse(body) const { sessionId, xml, svg } = JSON.parse(body)
const { sessionId } = data
if (!sessionId) { if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" }) res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" })) res.end(JSON.stringify({ error: "sessionId required" }))
return return
} }
const version = setState(sessionId, xml, svg)
// 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)
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version })) res.end(JSON.stringify({ success: true, version }))
} catch { } catch {
@@ -660,7 +638,6 @@ function getHtmlPage(sessionId: string): string {
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null; let pendingSvgExport = null;
let pendingAiSvg = false; let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -676,23 +653,6 @@ function getHtmlPage(sessionId: string): string {
// Fallback if export doesn't respond // Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000); setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) { } 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) // Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) { if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload; const dl = pendingDownload;
@@ -772,21 +732,11 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true; pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*'); 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) { if (s.version > currentVersion && s.xml) {
currentVersion = s.version; currentVersion = s.version;
loadDiagram(s.xml, true); 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 {} } catch {}
} }

View File

@@ -546,24 +546,16 @@ server.registerTool(
server.registerTool( server.registerTool(
"export_diagram", "export_diagram",
{ {
description: description: "Export the current diagram to a .drawio file.",
"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.",
inputSchema: { inputSchema: {
path: z path: z
.string() .string()
.describe( .describe(
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)", "File path to save the diagram (e.g., ./diagram.drawio)",
),
format: z
.enum(["drawio", "png", "svg"])
.optional()
.describe(
"Export format. If omitted, detected from file extension. Defaults to drawio.",
), ),
}, },
}, },
async ({ path, format }) => { async ({ path }) => {
try { try {
if (!currentSession) { if (!currentSession) {
return { return {
@@ -598,21 +590,16 @@ server.registerTool(
const fs = await import("node:fs/promises") const fs = await import("node:fs/promises")
const nodePath = await import("node:path") 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 let filePath = path
if (!filePath.endsWith(".drawio")) { if (!filePath.endsWith(".drawio")) {
filePath = `${filePath}.drawio` filePath = `${filePath}.drawio`
} }
const absolutePath = nodePath.resolve(filePath) const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8") await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`) log.info(`Diagram exported to ${absolutePath}`)
return { return {
content: [ content: [
{ {
@@ -621,87 +608,6 @@ server.registerTool(
}, },
], ],
} }
}
// 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}`
}
const absolutePath = nodePath.resolve(filePath)
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
// 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`,
},
],
}
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)

View File

@@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { describe, expect, it } from "vitest"
import { import {
getAIModel,
resolveBaseURL, resolveBaseURL,
supportsImageInput, supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
@@ -190,120 +189,3 @@ describe("supportsImageInput", () => {
expect(supportsImageInput("gemini-pro")).toBe(true) expect(supportsImageInput("gemini-pro")).toBe(true)
}) })
}) })
vi.mock("ollama-ai-provider-v2", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateOllama = vi.fn(() => mockProviderFn)
const mockOllama = vi.fn(() => mockModel)
return { createOllama: mockCreateOllama, ollama: mockOllama }
})
describe("Ollama API key security", () => {
let createOllamaMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY
savedEnv.OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL
delete process.env.OLLAMA_BASE_URL
const mod = await import("ollama-ai-provider-v2")
createOllamaMock = mod.createOllama as ReturnType<typeof vi.fn>
createOllamaMock.mockClear()
})
afterEach(() => {
process.env.OLLAMA_API_KEY = savedEnv.OLLAMA_API_KEY
process.env.OLLAMA_BASE_URL = savedEnv.OLLAMA_BASE_URL
})
it("applies server OLLAMA_API_KEY when no client baseUrl is provided", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
headers: { Authorization: "Bearer server-secret-key" },
}),
)
})
it("does NOT leak server OLLAMA_API_KEY when client provides a custom baseUrl", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
// When server has OLLAMA_API_KEY, the SSRF guard rejects
// client-provided baseUrl without an apiKey outright
expect(() =>
getAIModel({
provider: "ollama",
baseUrl: "https://evil-server.com",
modelId: "llama2",
}),
).toThrow("API key is required")
})
it("uses client API key when client provides both baseUrl and apiKey", () => {
process.env.OLLAMA_API_KEY = "server-secret-key"
getAIModel({
provider: "ollama",
baseUrl: "https://my-ollama.com",
apiKey: "client-key",
modelId: "llama2",
})
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://my-ollama.com",
headers: { Authorization: "Bearer client-key" },
}),
)
})
it("applies both server OLLAMA_BASE_URL and OLLAMA_API_KEY when no client overrides", () => {
process.env.OLLAMA_BASE_URL = "https://cloud.ollama.com"
process.env.OLLAMA_API_KEY = "server-key"
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://cloud.ollama.com",
headers: { Authorization: "Bearer server-key" },
}),
)
})
it("works when OLLAMA_API_KEY is set but OLLAMA_BASE_URL is not", () => {
process.env.OLLAMA_API_KEY = "server-key"
delete process.env.OLLAMA_BASE_URL
getAIModel({ provider: "ollama", modelId: "llama2" })
expect(createOllamaMock).toHaveBeenCalledTimes(1)
const callArgs = createOllamaMock.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("baseURL")
expect(callArgs).toEqual(
expect.objectContaining({
headers: { Authorization: "Bearer server-key" },
}),
)
})
it("allows client custom baseUrl without apiKey when no server OLLAMA_API_KEY", () => {
delete process.env.OLLAMA_API_KEY
getAIModel({
provider: "ollama",
baseUrl: "https://my-ollama.com",
modelId: "llama2",
})
expect(createOllamaMock).toHaveBeenCalledTimes(1)
const callArgs = createOllamaMock.mock.calls[0][0]
expect(callArgs.baseURL).toBe("https://my-ollama.com")
expect(callArgs).not.toHaveProperty("headers")
})
})

View File

@@ -45,72 +45,6 @@ describe("ServerModelsConfigSchema", () => {
ServerModelsConfigSchema.parse(invalidConfig as any), ServerModelsConfigSchema.parse(invalidConfig as any),
).toThrow() ).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", () => { describe("loadFlattenedServerModels", () => {
@@ -148,24 +82,4 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.provider).toBe("openai") expect(defaultModel.provider).toBe("openai")
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider 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"])
})
}) })