mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
14 Commits
fix/kimi-k
...
fix/electr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c083782802 | ||
|
|
a5d1554c3f | ||
|
|
e171fbcdd8 | ||
|
|
89d3968733 | ||
|
|
ac3570c1b0 | ||
|
|
41dc0b2b42 | ||
|
|
3041dafe2f | ||
|
|
7fbc857d3a | ||
|
|
d752524851 | ||
|
|
ecce0a96c7 | ||
|
|
cd33e131ef | ||
|
|
4624ad40a1 | ||
|
|
fa1548d3eb | ||
|
|
eb834a341f |
@@ -186,7 +186,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// Check if this is a server model with custom env var names
|
||||
let serverModelConfig: {
|
||||
apiKeyEnv?: string
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
provider?: string
|
||||
} = {}
|
||||
@@ -663,7 +663,7 @@ Available libraries:
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
@@ -708,7 +708,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, 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(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -34,6 +35,31 @@ export async function POST(req: Request) {
|
||||
{ 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
|
||||
const controller = new AbortController()
|
||||
@@ -44,9 +70,7 @@ export async function POST(req: Request) {
|
||||
let article
|
||||
try {
|
||||
article = await extract(url, undefined, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||
},
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -174,10 +174,21 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
const ollama = createOllama({
|
||||
baseURL: baseUrl || "http://localhost:11434",
|
||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
||||
// OLLAMA_API_KEY when the URL is also from server config.
|
||||
const ollamaApiKey = baseUrl
|
||||
? apiKey || undefined
|
||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
||||
const ollamaProvider = createOllama({
|
||||
baseURL:
|
||||
baseUrl ||
|
||||
process.env.OLLAMA_BASE_URL ||
|
||||
"https://ollama.com/api",
|
||||
...(ollamaApiKey && {
|
||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||
}),
|
||||
})
|
||||
model = ollama(modelId)
|
||||
model = ollamaProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
Command,
|
||||
CommandDialog,
|
||||
@@ -69,20 +70,62 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
}: ModelSelectorListProps) => {
|
||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
||||
const [showShadow, setShowShadow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
if (!listElement) return
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||
// Show shadow if there is more content below
|
||||
// Using a small threshold to handle fractional pixel rendering
|
||||
setShowShadow(
|
||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||
)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkScroll()
|
||||
|
||||
// Event listeners
|
||||
listElement.addEventListener("scroll", checkScroll)
|
||||
window.addEventListener("resize", checkScroll)
|
||||
|
||||
// Observe content changes (e.g. async loading of items)
|
||||
const observer = new MutationObserver(checkScroll)
|
||||
observer.observe(listElement, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
listElement.removeEventListener("scroll", checkScroll)
|
||||
window.removeEventListener("resize", checkScroll)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||
showShadow ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ interface ChatInputProps {
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
onConfigureModels?: () => void
|
||||
showUnvalidatedModels?: boolean
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
@@ -195,6 +196,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
onConfigureModels,
|
||||
showUnvalidatedModels = false,
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
@@ -551,6 +553,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
|
||||
@@ -1397,6 +1397,7 @@ export default function ChatPanel({
|
||||
models={modelConfig.models}
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onModelSelect={modelConfig.setSelectedModelId}
|
||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||
shouldFocus={shouldFocusInput}
|
||||
onFocused={() => setShouldFocusInput(false)}
|
||||
|
||||
@@ -282,6 +282,7 @@ export function ModelConfigDialog({
|
||||
// Check credentials based on provider type
|
||||
const isBedrock = selectedProvider.provider === "bedrock"
|
||||
const isEdgeOne = selectedProvider.provider === "edgeone"
|
||||
const isOllama = selectedProvider.provider === "ollama"
|
||||
const isVertexAI = selectedProvider.provider === "vertexai"
|
||||
if (isBedrock) {
|
||||
if (
|
||||
@@ -296,7 +297,7 @@ export function ModelConfigDialog({
|
||||
if (!selectedProvider.vertexApiKey) {
|
||||
return
|
||||
}
|
||||
} else if (!isEdgeOne && !selectedProvider.apiKey) {
|
||||
} else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1030,9 +1031,7 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
</>
|
||||
) : selectedProvider.provider ===
|
||||
"ollama" ||
|
||||
selectedProvider.provider ===
|
||||
"edgeone" ? (
|
||||
"edgeone" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -1100,6 +1099,9 @@ export function ModelConfigDialog({
|
||||
dict.modelConfig
|
||||
.apiKey
|
||||
}
|
||||
{selectedProvider.provider ===
|
||||
"ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -1163,7 +1165,9 @@ export function ModelConfigDialog({
|
||||
handleValidate
|
||||
}
|
||||
disabled={
|
||||
!selectedProvider.apiKey ||
|
||||
(selectedProvider.provider !==
|
||||
"ollama" &&
|
||||
!selectedProvider.apiKey) ||
|
||||
validationStatus ===
|
||||
"validating"
|
||||
}
|
||||
@@ -1661,12 +1665,16 @@ export function ModelConfigDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-unvalidated-models"
|
||||
checked={modelConfig.showUnvalidatedModels}
|
||||
onCheckedChange={
|
||||
modelConfig.setShowUnvalidatedModels
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground cursor-pointer">
|
||||
<Label
|
||||
htmlFor="show-unvalidated-models"
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{dict.modelConfig.showUnvalidatedModels}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
@@ -33,6 +34,7 @@ interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure?: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
@@ -83,6 +85,7 @@ export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
@@ -209,185 +212,241 @@ export function ModelSelector({
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 && models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedModelId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
{dict.modelConfig.serverDefault}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</ModelSelectorGroup>
|
||||
)}
|
||||
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={dict.modelConfig.serverModels}
|
||||
/>
|
||||
{Array.from(groupedServerModels.entries()).map(
|
||||
([
|
||||
providerLabel,
|
||||
{ provider, models: providerModels },
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
{serverModels.length === 0 && (
|
||||
<ModelSelectorGroup
|
||||
heading={dict.modelConfig.default}
|
||||
>
|
||||
<ModelSelectorItem
|
||||
value="__server_default__"
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
!selectedModelId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.modelId}
|
||||
onSelect={() =>
|
||||
handleSelect(model.id)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] || provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{model.modelId}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{/* Server Models Section */}
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(groupedUserModels.entries()).map(
|
||||
([
|
||||
providerLabel,
|
||||
{ provider, models: providerModels },
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.modelId}
|
||||
onSelect={() =>
|
||||
handleSelect(model.id)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
<>
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<Monitor />}
|
||||
label={
|
||||
dict.modelConfig.serverModels
|
||||
}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedServerModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`server-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
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>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.isDefault && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.serverDefaultModel
|
||||
}
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
>
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
.default
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info text */}
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
{/* User Models Section */}
|
||||
{userModels.length > 0 && (
|
||||
<>
|
||||
{serverModels.length > 0 && (
|
||||
<ModelSelectorSeparator />
|
||||
)}
|
||||
<ModelSelectorSectionHeader
|
||||
icon={<User />}
|
||||
label={dict.modelConfig.userModels}
|
||||
/>
|
||||
{Array.from(
|
||||
groupedUserModels.entries(),
|
||||
).map(
|
||||
([
|
||||
providerLabel,
|
||||
{
|
||||
provider,
|
||||
models: providerModels,
|
||||
},
|
||||
]) => (
|
||||
<ModelSelectorGroup
|
||||
key={`user-${providerLabel}`}
|
||||
heading={providerLabel}
|
||||
className="[&>[cmdk-group-heading]]:pl-4"
|
||||
>
|
||||
{providerModels.map(
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
)
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedModelId ===
|
||||
model.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ModelSelectorLogo
|
||||
provider={
|
||||
PROVIDER_LOGO_MAP[
|
||||
provider
|
||||
] ||
|
||||
provider
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<ModelSelectorName>
|
||||
{
|
||||
model.modelId
|
||||
}
|
||||
</ModelSelectorName>
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
),
|
||||
)}
|
||||
</ModelSelectorGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</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>
|
||||
</ModelSelectorRoot>
|
||||
</div>
|
||||
|
||||
@@ -77,12 +77,13 @@ function CommandInput({
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
@@ -91,7 +92,8 @@ function CommandList({
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
|
||||
367
docs/shape-libraries/material_design.md
Normal file
367
docs/shape-libraries/material_design.md
Normal 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)
|
||||
@@ -359,9 +359,9 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
baseUrl: "MODELSCOPE_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: "" },
|
||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,7 +94,8 @@ if (!gotTheLock) {
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost")
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1")
|
||||
) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
|
||||
const env: Record<string, string> = {
|
||||
NODE_ENV: "production",
|
||||
PORT: String(port),
|
||||
HOSTNAME: "localhost",
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import { app } from "electron"
|
||||
const PORT_CONFIG = {
|
||||
// Development mode uses fixed port for hot reload compatibility
|
||||
development: 6002,
|
||||
// Production mode uses fixed port (61337) to preserve localStorage
|
||||
// Falls back to sequential ports if unavailable
|
||||
production: 61337,
|
||||
// Legacy production port — tried first to preserve localStorage for existing users
|
||||
legacyProduction: 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)
|
||||
maxAttempts: 100,
|
||||
}
|
||||
@@ -27,7 +29,10 @@ let allocatedPort: number | null = null
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
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.close()
|
||||
resolve(true)
|
||||
@@ -39,12 +44,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - 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
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
* @param reuseExisting If true, try to reuse the previously allocated 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> {
|
||||
const isDev = !app.isPackaged
|
||||
@@ -64,7 +69,16 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
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)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
@@ -84,9 +98,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||
// Last resort: let the OS pick an available port
|
||||
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.",
|
||||
)
|
||||
}
|
||||
return `http://localhost:${allocatedPort}`
|
||||
return `http://127.0.0.1:${allocatedPort}`
|
||||
}
|
||||
|
||||
@@ -66,7 +66,11 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
|
||||
// Handle page title updates
|
||||
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)
|
||||
} else {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -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_SUMMARY=detailed
|
||||
|
||||
# Ollama (Local) Configuration
|
||||
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
||||
# Ollama Configuration (Local or Cloud)
|
||||
# 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)
|
||||
|
||||
# OpenRouter Configuration
|
||||
|
||||
@@ -34,8 +34,9 @@ export interface ClientOverrides {
|
||||
vertexApiKey?: string | null // Express Mode API key
|
||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||
headers?: Record<string, string>
|
||||
// Custom env var names for server models (allows multiple API keys per provider)
|
||||
apiKeyEnv?: string
|
||||
// Custom env var name(s) for server models
|
||||
// Can be a single string or array of strings for load balancing
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
@@ -99,10 +100,12 @@ export function resolveBaseURL(
|
||||
/**
|
||||
* Resolve API key from custom env var name or default env var.
|
||||
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
||||
* When multiple keys are configured, randomly selects one for load balancing.
|
||||
*
|
||||
* Priority:
|
||||
* 1. User-provided API key (overrides.apiKey)
|
||||
* 2. Custom env var 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)
|
||||
*/
|
||||
function resolveApiKey(
|
||||
@@ -110,7 +113,30 @@ function resolveApiKey(
|
||||
defaultEnvVar: string,
|
||||
): string | undefined {
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -516,12 +542,24 @@ function detectProvider(): ProviderName | null {
|
||||
/**
|
||||
* Validate that required API keys are present for the selected provider
|
||||
* @param provider - The provider to validate
|
||||
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
|
||||
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
|
||||
*/
|
||||
function validateProviderCredentials(
|
||||
provider: ProviderName,
|
||||
customApiKeyEnv?: string,
|
||||
customApiKeyEnv?: string | string[],
|
||||
): void {
|
||||
// Handle array of env var names - at least one must be set
|
||||
if (Array.isArray(customApiKeyEnv)) {
|
||||
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
|
||||
if (!hasAnyKey) {
|
||||
throw new Error(
|
||||
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
|
||||
`Please set at least one in your .env.local file.`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use custom env var name if provided, otherwise use default
|
||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||
if (requiredVar && !process.env[requiredVar]) {
|
||||
@@ -558,7 +596,7 @@ function validateProviderCredentials(
|
||||
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
|
||||
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI 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
|
||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||
@@ -573,13 +611,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||
// 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 (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||
overrides?.provider !== "edgeone" &&
|
||||
overrides?.provider !== "ollama"
|
||||
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
|
||||
) {
|
||||
throw new Error(
|
||||
`API key is required when using a custom base URL. ` +
|
||||
@@ -840,8 +880,19 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
case "ollama": {
|
||||
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
||||
if (baseURL) {
|
||||
const customOllama = createOllama({ baseURL })
|
||||
// SECURITY: When client provides a custom base URL, only use
|
||||
// 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)
|
||||
} else {
|
||||
model = ollama(modelId)
|
||||
@@ -1179,7 +1230,12 @@ export function supportsImageInput(modelId: string): boolean {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ export const ServerProviderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
provider: ProviderNameSchema,
|
||||
models: z.array(z.string().min(1)),
|
||||
// Optional: custom environment variable name for API key
|
||||
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
|
||||
apiKeyEnv: z.string().min(1).optional(),
|
||||
// Optional: custom environment variable name(s) for API key
|
||||
// Can be a single string or array of strings for load balancing
|
||||
// e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"]
|
||||
apiKeyEnv: z
|
||||
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
||||
.optional(),
|
||||
// Optional: custom environment variable name for base URL
|
||||
baseUrlEnv: z.string().min(1).optional(),
|
||||
// Optional: mark the first model in this provider as the default
|
||||
@@ -36,8 +39,9 @@ export interface FlattenedServerModel {
|
||||
provider: ProviderName
|
||||
providerLabel: string
|
||||
isDefault: boolean
|
||||
// Custom env var names for credentials (optional)
|
||||
apiKeyEnv?: string
|
||||
// Custom env var name(s) for API key (optional)
|
||||
// Can be a single string or array of strings for load balancing
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ parameters: {
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, 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: {
|
||||
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---
|
||||
|
||||
@@ -61,7 +61,7 @@ IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating 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:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
@@ -92,7 +92,7 @@ Note that:
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), 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.
|
||||
|
||||
When using edit_diagram tool:
|
||||
|
||||
@@ -73,8 +73,9 @@ export interface FlattenedModel {
|
||||
source?: "user" | "server"
|
||||
// Whether this model is the server default (matches AI_MODEL env var)
|
||||
isDefault?: boolean
|
||||
// Custom env var names for server models (allows multiple API keys per provider)
|
||||
apiKeyEnv?: string
|
||||
// Custom env var name(s) for server models
|
||||
// Can be a single string or array of strings for load balancing
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
@@ -103,7 +104,7 @@ export const PROVIDER_INFO: Record<
|
||||
bedrock: { label: "Amazon Bedrock" },
|
||||
ollama: {
|
||||
label: "Ollama",
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
defaultBaseUrl: "https://ollama.com/api",
|
||||
},
|
||||
openrouter: {
|
||||
label: "OpenRouter",
|
||||
@@ -263,6 +264,7 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"Qwen/Qwen2-VL-72B-Instruct",
|
||||
"qwen3.5-plus",
|
||||
],
|
||||
sglang: [
|
||||
// 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-VL-235B-A22B-Instruct",
|
||||
"Qwen/Qwen3-32B",
|
||||
"qwen3.5-plus",
|
||||
// DeepSeek
|
||||
"deepseek-ai/DeepSeek-R1-0528",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
|
||||
4672
package-lock.json
generated
4672
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -43,15 +43,15 @@
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
"@formatjs/intl-localematcher": "^0.7.2",
|
||||
"@formatjs/intl-localematcher": "^0.8.0",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
"@langfuse/otel": "^4.4.4",
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
"@next/third-parties": "^16.0.6",
|
||||
"@opennextjs/cloudflare": "1.14.8",
|
||||
"@opennextjs/cloudflare": "1.16.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||
"@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",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -72,7 +72,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"jsonrepair": "^3.13.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.23.25",
|
||||
"nanoid": "^5.0.0",
|
||||
"negotiator": "^1.0.0",
|
||||
@@ -129,7 +129,7 @@
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
|
||||
69
packages/mcp-server/package-lock.json
generated
69
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.16",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
@@ -469,9 +469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -481,12 +481,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
||||
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -494,14 +494,15 @@
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jose": "^6.1.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -520,9 +521,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
|
||||
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
|
||||
"version": "24.10.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz",
|
||||
"integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1074,10 +1075,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
@@ -1260,9 +1264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
|
||||
"integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -1348,6 +1352,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -2051,9 +2064,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -2061,9 +2074,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25 || ^4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -69,6 +69,8 @@ interface SessionState {
|
||||
lastUpdated: Date
|
||||
svg?: string // Cached SVG from last browser save
|
||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||
exportData?: string // Base64/SVG data returned by browser after export
|
||||
}
|
||||
|
||||
export const stateStore = new Map<string, SessionState>()
|
||||
@@ -91,6 +93,8 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||
lastUpdated: new Date(),
|
||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
return newVersion
|
||||
@@ -255,6 +259,7 @@ function handleStateApi(
|
||||
xml: state?.xml || null,
|
||||
version: state?.version || 0,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
@@ -264,13 +269,30 @@ function handleStateApi(
|
||||
})
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const { sessionId, xml, svg } = JSON.parse(body)
|
||||
const data = JSON.parse(body)
|
||||
const { sessionId } = data
|
||||
if (!sessionId) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||
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.end(JSON.stringify({ success: true, version }))
|
||||
} catch {
|
||||
@@ -638,6 +660,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
let pendingSvgExport = null;
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
@@ -653,6 +676,23 @@ function getHtmlPage(sessionId: string): string {
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg)
|
||||
// Verify the response matches the requested format to avoid capturing
|
||||
// unrelated exports (autosave SVG, sync XML)
|
||||
if (pendingMcpExport) {
|
||||
const d = msg.data;
|
||||
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
||||
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
||||
if (isPng || isSvg) {
|
||||
pendingMcpExport = null;
|
||||
fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d })
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||
const dl = pendingDownload;
|
||||
@@ -732,11 +772,21 @@ function getHtmlPage(sessionId: string): string {
|
||||
pendingSyncExport = true;
|
||||
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) {
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg) - after version update
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const exportOpts = s.exportFormat === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
// Timeout: reset if draw.io never responds
|
||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -546,16 +546,24 @@ server.registerTool(
|
||||
server.registerTool(
|
||||
"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: {
|
||||
path: z
|
||||
.string()
|
||||
.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 {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
@@ -590,21 +598,107 @@ server.registerTool(
|
||||
const fs = await import("node:fs/promises")
|
||||
const nodePath = await import("node:path")
|
||||
|
||||
let filePath = path
|
||||
if (!filePath.endsWith(".drawio")) {
|
||||
filePath = `${filePath}.drawio`
|
||||
// Detect format from extension if not specified
|
||||
const ext = nodePath.extname(path).toLowerCase()
|
||||
const detectedFormat =
|
||||
format ||
|
||||
(ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio")
|
||||
|
||||
// Original .drawio export path (unchanged logic)
|
||||
if (detectedFormat === "drawio") {
|
||||
let filePath = path
|
||||
if (!filePath.endsWith(".drawio")) {
|
||||
filePath = `${filePath}.drawio`
|
||||
}
|
||||
const absolutePath = nodePath.resolve(filePath)
|
||||
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
||||
log.info(`Diagram exported to ${absolutePath}`)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// PNG or SVG: request browser to export via iframe
|
||||
let filePath = path
|
||||
if (ext !== `.${detectedFormat}`) {
|
||||
if (ext === ".drawio" || ext === ".png" || ext === ".svg") {
|
||||
filePath = filePath.slice(0, -ext.length)
|
||||
}
|
||||
filePath = `${filePath}.${detectedFormat}`
|
||||
}
|
||||
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 {
|
||||
content: [
|
||||
{
|
||||
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`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
getAIModel,
|
||||
resolveBaseURL,
|
||||
supportsImageInput,
|
||||
supportsPromptCaching,
|
||||
@@ -189,3 +190,120 @@ describe("supportsImageInput", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,72 @@ describe("ServerModelsConfigSchema", () => {
|
||||
ServerModelsConfigSchema.parse(invalidConfig as any),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it("accepts apiKeyEnv as single string", () => {
|
||||
const config: ServerModelsConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: "OpenAI Server",
|
||||
provider: "openai",
|
||||
models: ["gpt-4o"],
|
||||
apiKeyEnv: "OPENAI_API_KEY_TEAM_A",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const parsed = ServerModelsConfigSchema.parse(config)
|
||||
expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A")
|
||||
})
|
||||
|
||||
it("accepts apiKeyEnv as array of strings for load balancing", () => {
|
||||
const config: ServerModelsConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: "OpenAI Server",
|
||||
provider: "openai",
|
||||
models: ["gpt-4o"],
|
||||
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const parsed = ServerModelsConfigSchema.parse(config)
|
||||
expect(parsed.providers[0].apiKeyEnv).toEqual([
|
||||
"OPENAI_KEY_1",
|
||||
"OPENAI_KEY_2",
|
||||
"OPENAI_KEY_3",
|
||||
])
|
||||
})
|
||||
|
||||
it("rejects empty array for apiKeyEnv", () => {
|
||||
const config = {
|
||||
providers: [
|
||||
{
|
||||
name: "OpenAI Server",
|
||||
provider: "openai",
|
||||
models: ["gpt-4o"],
|
||||
apiKeyEnv: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
||||
})
|
||||
|
||||
it("rejects empty string in apiKeyEnv array", () => {
|
||||
const config = {
|
||||
providers: [
|
||||
{
|
||||
name: "OpenAI Server",
|
||||
provider: "openai",
|
||||
models: ["gpt-4o"],
|
||||
apiKeyEnv: ["VALID_KEY", ""],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadFlattenedServerModels", () => {
|
||||
@@ -82,4 +148,24 @@ describe("loadFlattenedServerModels", () => {
|
||||
expect(defaultModel.provider).toBe("openai")
|
||||
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
||||
})
|
||||
|
||||
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
||||
const config: ServerModelsConfig = {
|
||||
providers: [
|
||||
{
|
||||
name: "OpenAI LoadBalanced",
|
||||
provider: "openai",
|
||||
models: ["gpt-4o"],
|
||||
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"],
|
||||
},
|
||||
],
|
||||
}
|
||||
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
|
||||
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
|
||||
|
||||
const models = await loadFlattenedServerModels()
|
||||
|
||||
expect(models.length).toBe(1)
|
||||
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user