Compare commits

...

14 Commits

Author SHA1 Message Date
dayuan.jiang
c083782802 fix: resolve Electron startup failures on Windows and Linux
- Try legacy port (61337) first to preserve existing users' localStorage,
  fall back to 13370 which is below the Windows Hyper-V ephemeral range (#705)
- Bind server and all URL references to 127.0.0.1 instead of localhost
  to fix IPv4/IPv6 mismatch on Linux (#684)
- Add OS-assigned port fallback (port 0) so startup never throws
- Log error codes in port checks for easier debugging
- Update localhost guards in index.ts and window-manager.ts to also
  match 127.0.0.1

Related: #705, #684
2026-02-26 22:36:07 +09:00
Marvelous Ikponmwosa
a5d1554c3f Add Ollama Cloud support with Base URL and API Key configuration (#692)
* Add Ollama Cloud support with Base URL and API Key configuration

* implemented feedback

* fix: use OLLAMA_BASE_URL env fallback in validate-model endpoint

* Remove dedicated Ollama configuration block

* security(ollama): prevent API key leak to client-controlled URLs

* added test

* fix: security hardening and Ollama Cloud default URL

- Add server OLLAMA_API_KEY fallback to validate-model endpoint with
  SSRF guard mirroring ai-providers.ts
- Tighten top-level SSRF exemption: only exempt Ollama when no server
  OLLAMA_API_KEY is configured
- Update Electron config to support OLLAMA_API_KEY env var
- Change default Ollama URL from localhost:11434 to ollama.com/api
  (Ollama Cloud) for web UI users
- Add tests for server env combo, API-key-only, and SSRF guard scenarios

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-02-26 21:55:21 +09:00
w
e171fbcdd8 feat: add qwen3.5-plus vision model support (#706)
Add qwen3.5-plus to SiliconFlow and ModelScope suggested models.
Mark qwen3.5-plus as a vision-capable model in supportsImageInput check.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:45:57 +09:00
Elshad Humbatli
89d3968733 Fix: return clear error for PDF URLs in content extraction (#694)
* fix: return clear error for PDF urls

* handle timeout thoroughly + use hoisting for user agent
2026-02-13 18:44:59 +09:00
renovate[bot]
ac3570c1b0 fix(deps): update minor and patch dependencies (#671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 18:50:49 +00:00
Dayuan Jiang
41dc0b2b42 feat: add Material Design Icons shape library (#688)
* feat: add Material Design Icons shape library (#685)

Add Google Material Design Icons as a new shape library using Google's
CDN. Includes top 300 most popular icons by usage, and updates system
prompts to guide the AI to call get_shape_library before using any icon
library.

* fix: align get_shape_library guidance for non-cloud icon libraries
2026-02-07 13:57:00 +09:00
Dayuan Jiang
3041dafe2f feat: add PNG/SVG export to MCP server (#687)
* feat: add PNG/SVG export support to MCP server export_diagram tool

Previously export_diagram only supported .drawio XML files. This adds
PNG and SVG export by leveraging the existing browser sync mechanism:
the MCP tool sets an exportFormat flag on the session state, the browser
detects it via polling and triggers an iframe export, then POSTs the
result back as exportData which the tool reads and writes to disk.

* fix: address PR review feedback for export feature

- Validate exportData is a string in POST /api/state
- Update lastUpdated in setExportFormat to prevent session expiry
- Gate export postMessage on isReady to avoid lost messages
- Remove unused fmt variable
- Fix double extension when path has a different supported extension

* fix: resolve high severity npm audit vulnerabilities

Run npm audit fix to update @aws-sdk and @smithy transitive dependencies
that had high severity advisories, which was failing the CI security audit step.

* fix: address second round of PR review feedback

- Add 8s timeout for pendingMcpExport to prevent permanent blocking
- Move export trigger after version update in poll() to export latest diagram
- Return 404 when session not found for exportData POST
- Sync browser state before .drawio export to avoid stale XML
- Handle URL-encoded SVG data URIs in addition to base64

* fix: address third round of PR review feedback

- Sync browser state before PNG/SVG export (not just drawio)
- Add 10MB body size limit on POST /api/state
- Validate export response format matches request to prevent race conditions

* refactor: remove over-engineered defensive code from export feature

Strip unnecessary validation/guards added from Copilot review that
don't make sense for a localhost-only MCP server: body size limit,
type validation, 404 for missing session, lastUpdated refresh,
URL-encoded SVG handling. Also deduplicate requestSync call.

* refactor: keep original drawio export path unchanged

Don't restructure the existing drawio logic - just add png/svg
as a separate branch after it.

* refactor: remove redundant helper functions, inline state access

Remove setExportFormat/getExportData/clearExportData wrappers that
were each called once. Access state fields directly via getState().

* chore: bump mcp-server version to 0.1.16
2026-02-07 12:55:09 +09:00
Khairil Rahman Hakiki
7fbc857d3a feat(ui): conditional model selector shadow logic (#681)
* feat(ui): conditional model selector shadow logic (#678)

- Update ModelSelectorList to conditionally render shadow based on scroll state
- Update CommandList to forward ref for scroll detection
- Resolves #678

* Update components/ui/command.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(ui): update listRef type to match CommandList forwarded ref

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-07 12:13:56 +09:00
Vishakha Agrawal
d752524851 make show unvalidated models clickable (#680) 2026-02-07 10:36:13 +09:00
Gideon Ayeni
ecce0a96c7 enhancement: Pin Configure Models button to bottom of model selector … (#665)
* enhancement: Pin Configure Models button to bottom of model selector (#637)

Keep "Configure Models..." and info text fixed at bottom of dropdown when the model list scrolls. Wire button to open Model Config dialog.

* Address PR review: use ModelSelectorItem, z-10 footer, padding on wrapper

* fix: reduce spacing between Configure Models button and info text

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-02-02 20:08:34 +09:00
Dayuan Jiang
cd33e131ef feat: add API key load balancing for providers (#676)
Support multiple API keys per provider with random selection for load
balancing. When AI_MODELS_CONFIG has multiple apiKeyEnv values for
a provider, requests will randomly select one available key.

- Update schema to accept apiKeyEnv as string or string array
- Add random key selection in resolveApiKey()
- Update validation to check at least one key exists
- Add tests for array format support
2026-02-02 15:54:35 +09:00
Dayuan Jiang
4624ad40a1 fix: enable image support for Kimi K2.5 model (#670)
* 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.

* 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:14:27 +09:00
renovate[bot]
fa1548d3eb chore(deps): update electron packages (#668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:45:23 +09:00
renovate[bot]
eb834a341f fix(deps): update core framework packages (#667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:31:15 +09:00
28 changed files with 3006 additions and 3242 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 apiKeyEnv?: string | 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 - UI/Mockups: android, material_design
- 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, 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, material_design, lean_mapping, openstack, rack`
} }
console.error( console.error(
`[get_shape_library] Error loading "${library}":`, `[get_shape_library] Error loading "${library}":`,

View File

@@ -5,6 +5,7 @@ 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 {
@@ -34,6 +35,31 @@ 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()
@@ -44,9 +70,7 @@ export async function POST(req: Request) {
let article let article
try { try {
article = await extract(url, undefined, { article = await extract(url, undefined, {
headers: { headers: { "User-Agent": USER_AGENT },
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
signal: controller.signal, signal: controller.signal,
}) })
} catch (err: any) { } catch (err: any) {

View File

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

View File

@@ -1,5 +1,6 @@
import { Cloud } from "lucide-react" import { Cloud } from "lucide-react"
import type { ComponentProps, ReactNode } from "react" import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import { import {
Command, Command,
CommandDialog, CommandDialog,
@@ -69,20 +70,62 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({ export const ModelSelectorList = ({
className, className,
...props ...props
}: ModelSelectorListProps) => ( }: ModelSelectorListProps) => {
<div className="relative"> const listRef = useRef<ElementRef<typeof CommandList>>(null)
<CommandList const [showShadow, setShowShadow] = useState(false)
className={cn(
// Hide scrollbar on all platforms useEffect(() => {
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", const listElement = listRef.current
className, if (!listElement) return
)}
{...props} const checkScroll = () => {
/> const { scrollTop, scrollHeight, clientHeight } = listElement
{/* Bottom shadow indicator for scrollable content */} // Show shadow if there is more content below
<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" /> // Using a small threshold to handle fractional pixel rendering
</div> 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>
)
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty> export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -171,6 +171,7 @@ 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
@@ -195,6 +196,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
models = [], models = [],
selectedModelId, selectedModelId,
onModelSelect = () => {}, onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false, showUnvalidatedModels = false,
shouldFocus = false, shouldFocus = false,
onFocused, onFocused,
@@ -551,6 +553,7 @@ 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,6 +1397,7 @@ 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,6 +282,7 @@ 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 (
@@ -296,7 +297,7 @@ export function ModelConfigDialog({
if (!selectedProvider.vertexApiKey) { if (!selectedProvider.vertexApiKey) {
return return
} }
} else if (!isEdgeOne && !selectedProvider.apiKey) { } else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) {
return return
} }
@@ -1030,9 +1031,7 @@ export function ModelConfigDialog({
</div> </div>
</> </>
) : selectedProvider.provider === ) : selectedProvider.provider ===
"ollama" || "edgeone" ? (
selectedProvider.provider ===
"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">
<Button <Button
@@ -1100,6 +1099,9 @@ 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">
@@ -1163,7 +1165,9 @@ export function ModelConfigDialog({
handleValidate handleValidate
} }
disabled={ disabled={
!selectedProvider.apiKey || (selectedProvider.provider !==
"ollama" &&
!selectedProvider.apiKey) ||
validationStatus === validationStatus ===
"validating" "validating"
} }
@@ -1661,12 +1665,16 @@ 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 className="text-xs text-muted-foreground cursor-pointer"> <Label
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,6 +7,7 @@ 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"
@@ -33,6 +34,7 @@ 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
} }
@@ -83,6 +85,7 @@ export function ModelSelector({
models, models,
selectedModelId, selectedModelId,
onSelect, onSelect,
onConfigure,
disabled = false, disabled = false,
showUnvalidatedModels = false, showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
@@ -209,185 +212,241 @@ export function ModelSelector({
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} placeholder={dict.modelConfig.searchModels}
/> />
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<ModelSelectorEmpty> <div className="flex-1 min-h-0 overflow-hidden">
{displayModels.length === 0 && models.length > 0 <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
? dict.modelConfig.noVerifiedModels <ModelSelectorEmpty>
: dict.modelConfig.noModelsFound} {displayModels.length === 0 &&
</ModelSelectorEmpty> models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */} {/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && ( {serverModels.length === 0 && (
<ModelSelectorGroup <ModelSelectorGroup
heading={dict.modelConfig.default} heading={dict.modelConfig.default}
> >
<ModelSelectorItem <ModelSelectorItem
value="__server_default__" value="__server_default__"
onSelect={handleSelect} onSelect={handleSelect}
className={cn( className={cn(
"cursor-pointer", "cursor-pointer",
!selectedModelId && "bg-accent", !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"
> >
{providerModels.map((model) => ( <Check
<ModelSelectorItem className={cn(
key={model.id} "mr-2 h-4 w-4",
value={model.modelId} !selectedModelId
onSelect={() => ? "opacity-100"
handleSelect(model.id) : "opacity-0",
} )}
className="cursor-pointer" />
> <Server className="mr-2 h-4 w-4 text-muted-foreground" />
<Check <ModelSelectorName>
className={cn( {dict.modelConfig.serverDefault}
"mr-2 h-4 w-4", </ModelSelectorName>
selectedModelId === </ModelSelectorItem>
model.id </ModelSelectorGroup>
? "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>
),
)} )}
</>
)}
{/* User Models Section */} {/* Server Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && ( {serverModels.length > 0 && (
<ModelSelectorSeparator /> <>
)} <ModelSelectorSectionHeader
<ModelSelectorSectionHeader icon={<Monitor />}
icon={<User />} label={
label={dict.modelConfig.userModels} dict.modelConfig.serverModels
/> }
{Array.from(groupedUserModels.entries()).map( />
([ {Array.from(
providerLabel, groupedServerModels.entries(),
{ provider, models: providerModels }, ).map(
]) => ( ([
<ModelSelectorGroup providerLabel,
key={`user-${providerLabel}`} {
heading={providerLabel} provider,
className="[&>[cmdk-group-heading]]:pl-4" models: providerModels,
> },
{providerModels.map((model) => ( ]) => (
<ModelSelectorItem <ModelSelectorGroup
key={model.id} key={`server-${providerLabel}`}
value={model.modelId} heading={providerLabel}
onSelect={() => className="[&>[cmdk-group-heading]]:pl-4"
handleSelect(model.id)
}
className="cursor-pointer"
> >
<Check {providerModels.map(
className={cn( (model) => (
"mr-2 h-4 w-4", <ModelSelectorItem
selectedModelId === key={model.id}
model.id value={
? "opacity-100" model.modelId
: "opacity-0", }
)} onSelect={() =>
/> handleSelect(
<ModelSelectorLogo model.id,
provider={ )
PROVIDER_LOGO_MAP[ }
provider className="cursor-pointer"
] || provider >
} <Check
className="mr-2" className={cn(
/> "mr-2 h-4 w-4",
<ModelSelectorName> selectedModelId ===
{model.modelId} model.id
</ModelSelectorName> ? "opacity-100"
{model.validated !== : "opacity-0",
true && ( )}
<span />
title={ <ModelSelectorLogo
dict.modelConfig provider={
.unvalidatedModelWarning PROVIDER_LOGO_MAP[
} provider
> ] ||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" /> provider
</span> }
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>
),
)} )}
</ModelSelectorItem> </ModelSelectorGroup>
))} ),
</ModelSelectorGroup> )}
), </>
)} )}
</>
)}
{/* Info text */} {/* User Models Section */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t"> {userModels.length > 0 && (
{showUnvalidatedModels <>
? dict.modelConfig.allModelsShown {serverModels.length > 0 && (
: dict.modelConfig.onlyVerifiedShown} <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>
</div> </div>
</ModelSelectorList> {/* 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>
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelectorRoot> </ModelSelectorRoot>
</div> </div>

View File

@@ -77,12 +77,13 @@ function CommandInput({
) )
} }
function CommandList({ const CommandList = React.forwardRef<
className, React.ElementRef<typeof CommandPrimitive.List>,
...props React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
}: React.ComponentProps<typeof CommandPrimitive.List>) { >(({ className, ...props }, ref) => {
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",
@@ -91,7 +92,8 @@ function CommandList({
{...props} {...props}
/> />
) )
} })
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
function CommandEmpty({ function CommandEmpty({
...props ...props

View File

@@ -0,0 +1,367 @@
# 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 and ollama don't use API keys in the same way // bedrock doesn't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" }, bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" }, ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
} }
/** /**

View File

@@ -94,7 +94,8 @@ 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: "localhost", HOSTNAME: "127.0.0.1",
// 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,9 +9,11 @@ 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,
// Production mode uses fixed port (61337) to preserve localStorage // Legacy production port — tried first to preserve localStorage for existing users
// Falls back to sequential ports if unavailable legacyProduction: 61337,
production: 61337, // New production port below the ephemeral range (49152-65535)
// 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,
} }
@@ -27,7 +29,10 @@ 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", () => resolve(false)) server.once("error", (err: NodeJS.ErrnoException) => {
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("listening", () => { server.once("listening", () => {
server.close() server.close()
resolve(true) resolve(true)
@@ -39,12 +44,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 (61337) to preserve localStorage * - In production: uses fixed port (13370) 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
@@ -64,7 +69,16 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// Try preferred port first // In production, try legacy port first to preserve existing users' localStorage
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) { if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort allocatedPort = preferredPort
return preferredPort return preferredPort
@@ -84,9 +98,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
} }
} }
throw new Error( // Last resort: let the OS pick an available port
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`, console.warn(
"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
} }
/** /**
@@ -113,5 +141,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.", "No port allocated yet. Call findAvailablePort() first.",
) )
} }
return `http://localhost:${allocatedPort}` return `http://127.0.0.1:${allocatedPort}`
} }

View File

@@ -66,7 +66,11 @@ 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 (title && !title.includes("localhost")) { if (
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,8 +59,9 @@ 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 (Local) Configuration # Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost # OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
# 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,8 +34,9 @@ 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 names for server models (allows multiple API keys per provider) // Custom env var name(s) for server models
apiKeyEnv?: string // Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
@@ -99,10 +100,12 @@ 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 from ai-models.json (overrides.apiKeyEnv) * 2. Custom env var(s) 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(
@@ -110,7 +113,30 @@ 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]
} }
@@ -516,12 +542,24 @@ 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 (from ai-models.json apiKeyEnv) * @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
*/ */
function validateProviderCredentials( function validateProviderCredentials(
provider: ProviderName, provider: ProviderName,
customApiKeyEnv?: string, customApiKeyEnv?: string | 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]) {
@@ -558,7 +596,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 http://localhost:11434) * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
* - 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)
@@ -573,13 +611,15 @@ 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 and Ollama providers don't require API keys // Exception: EdgeOne doesn'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" !(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
) { ) {
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. ` +
@@ -840,8 +880,19 @@ 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
if (baseURL) { // SECURITY: When client provides a custom base URL, only use
const customOllama = createOllama({ baseURL }) // client-provided API key. Never fall back to server OLLAMA_API_KEY
// 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)
@@ -1161,8 +1212,15 @@ export function supportsImageInput(modelId: string): boolean {
lowerModelId.includes("vision") || lowerModelId.includes("vl") lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant) // Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 models don't support images // Kimi K2 doesn't support images, but K2.5 does
if (lowerModelId.includes("kimi") && !hasVisionIndicator) { // Only block kimi-k2 specifically, not other Kimi models
if (
(lowerModelId.includes("kimi-k2") ||
lowerModelId.includes("kimi_k2")) &&
!hasVisionIndicator &&
!lowerModelId.includes("2.5") &&
!lowerModelId.includes("k2.5")
) {
return false return false
} }
@@ -1172,7 +1230,12 @@ export function supportsImageInput(modelId: string): boolean {
} }
// Qwen text models (not vision variants like qwen-vl) // Qwen text models (not vision variants like qwen-vl)
if (lowerModelId.includes("qwen") && !hasVisionIndicator) { // qwen3.5-plus is a vision model
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5-plus")
) {
return false return false
} }

View File

@@ -14,9 +14,12 @@ 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 for API key // Optional: custom environment variable name(s) for API key
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY" // Can be a single string or array of strings for load balancing
apiKeyEnv: z.string().min(1).optional(), // 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 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
@@ -36,8 +39,9 @@ export interface FlattenedServerModel {
provider: ProviderName provider: ProviderName
providerLabel: string providerLabel: string
isDefault: boolean isDefault: boolean
// Custom env var names for credentials (optional) // Custom env var name(s) for API key (optional)
apiKeyEnv?: string // Can be a single string or array of strings for load balancing
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, etc.) before creating diagrams with cloud/tech icons. 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.
parameters: { parameters: {
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc. library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, 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 cloud architecture or technical diagrams (call BEFORE display_diagram) - Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — 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), call get_shape_library first to discover available icon shapes and their syntax. - 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.
- 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,8 +73,9 @@ 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 names for server models (allows multiple API keys per provider) // Custom env var name(s) for server models
apiKeyEnv?: string // Can be a single string or array of strings for load balancing
apiKeyEnv?: string | string[]
baseUrlEnv?: string baseUrlEnv?: string
} }
@@ -103,7 +104,7 @@ export const PROVIDER_INFO: Record<
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: { ollama: {
label: "Ollama", label: "Ollama",
defaultBaseUrl: "http://localhost:11434", defaultBaseUrl: "https://ollama.com/api",
}, },
openrouter: { openrouter: {
label: "OpenRouter", label: "OpenRouter",
@@ -263,6 +264,7 @@ 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
@@ -292,6 +294,7 @@ 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",

4672
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.7.2", "@formatjs/intl-localematcher": "^0.8.0",
"@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.14.8", "@opennextjs/cloudflare": "1.16.1",
"@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.209.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.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.562.0", "lucide-react": "^0.563.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.1", "eslint-config-next": "16.1.6",
"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.12", "version": "0.1.16",
"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.12", "version": "0.1.16",
"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.7", "version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"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.25.2", "version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.7", "@hono/node-server": "^1.19.9",
"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,14 +494,15 @@
"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.0.1", "express": "^5.2.1",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^8.2.1",
"jose": "^6.1.1", "hono": "^4.11.4",
"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.0" "zod-to-json-schema": "^3.25.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -520,9 +521,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.10.6", "version": "24.10.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz",
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==", "integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1074,10 +1075,13 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "7.5.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT", "license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
}, },
@@ -1260,9 +1264,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.1", "version": "4.11.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
"integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -1348,6 +1352,15 @@
"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",
@@ -2051,9 +2064,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.5", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
@@ -2061,9 +2074,9 @@
} }
}, },
"node_modules/zod-to-json-schema": { "node_modules/zod-to-json-schema": {
"version": "3.25.0", "version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"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.15", "version": "0.1.16",
"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,6 +69,8 @@ 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>()
@@ -91,6 +93,8 @@ 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
@@ -255,6 +259,7 @@ 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") {
@@ -264,13 +269,30 @@ function handleStateApi(
}) })
req.on("end", () => { req.on("end", () => {
try { try {
const { sessionId, xml, svg } = JSON.parse(body) const data = 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 {
@@ -638,6 +660,7 @@ 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;
@@ -653,6 +676,23 @@ 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;
@@ -732,11 +772,21 @@ 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 // Load new diagram from server (before export, so we export latest)
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,16 +546,24 @@ server.registerTool(
server.registerTool( server.registerTool(
"export_diagram", "export_diagram",
{ {
description: "Export the current diagram to a .drawio file.", 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.",
inputSchema: { inputSchema: {
path: z path: z
.string() .string()
.describe( .describe(
"File path to save the diagram (e.g., ./diagram.drawio)", "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.",
), ),
}, },
}, },
async ({ path }) => { async ({ path, format }) => {
try { try {
if (!currentSession) { if (!currentSession) {
return { return {
@@ -590,21 +598,107 @@ 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")
let filePath = path // Detect format from extension if not specified
if (!filePath.endsWith(".drawio")) { const ext = nodePath.extname(path).toLowerCase()
filePath = `${filePath}.drawio` 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}`
}
const absolutePath = nodePath.resolve(filePath) const absolutePath = nodePath.resolve(filePath)
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
log.info(`Diagram exported to ${absolutePath}`) 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 { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`, text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`,
}, },
], ],
} }

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { import {
getAIModel,
resolveBaseURL, resolveBaseURL,
supportsImageInput, supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
@@ -168,6 +169,11 @@ describe("supportsImageInput", () => {
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false) expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
}) })
it("returns true for Kimi K2.5 models (supports vision)", () => {
expect(supportsImageInput("kimi-k2.5")).toBe(true)
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
})
it("returns false for DeepSeek text models", () => { it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false) expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false) expect(supportsImageInput("deepseek-coder")).toBe(false)
@@ -184,3 +190,120 @@ 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,6 +45,72 @@ 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", () => {
@@ -82,4 +148,24 @@ 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"])
})
}) })