mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
2 Commits
fix/electr
...
fix/kimi-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3a498306 | ||
|
|
2712f6d67c |
@@ -186,7 +186,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
// Check if this is a server model with custom env var names
|
// Check if this is a server model with custom env var names
|
||||||
let serverModelConfig: {
|
let serverModelConfig: {
|
||||||
apiKeyEnv?: string | string[]
|
apiKeyEnv?: string
|
||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
} = {}
|
} = {}
|
||||||
@@ -663,7 +663,7 @@ Available libraries:
|
|||||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||||
- Business: bpmn, lean_mapping
|
- Business: bpmn, lean_mapping
|
||||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||||
- UI/Mockups: android, material_design
|
- UI/Mockups: android
|
||||||
- Enterprise: citrix, sap, mscae, atlassian
|
- Enterprise: citrix, sap, mscae, atlassian
|
||||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||||
- Icons: webicons
|
- Icons: webicons
|
||||||
@@ -708,7 +708,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
if (
|
if (
|
||||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||||
) {
|
) {
|
||||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, lean_mapping, openstack, rack`
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
`[get_shape_library] Error loading "${library}":`,
|
`[get_shape_library] Error loading "${library}":`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
|||||||
|
|
||||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
const EXTRACT_TIMEOUT_MS = 15000
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -35,31 +34,6 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const headController = new AbortController()
|
|
||||||
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
|
||||||
try {
|
|
||||||
const headResponse = await fetch(url, {
|
|
||||||
method: "HEAD",
|
|
||||||
headers: { "User-Agent": USER_AGENT },
|
|
||||||
signal: headController.signal,
|
|
||||||
})
|
|
||||||
const contentType = headResponse.headers.get("content-type")
|
|
||||||
if (contentType?.includes("application/pdf")) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
|
|
||||||
},
|
|
||||||
{ status: 422 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"HEAD pre-check failed, proceeding with extraction:",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
clearTimeout(headTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract article content with timeout to avoid tying up server resources
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -70,7 +44,9 @@ export async function POST(req: Request) {
|
|||||||
let article
|
let article
|
||||||
try {
|
try {
|
||||||
article = await extract(url, undefined, {
|
article = await extract(url, undefined, {
|
||||||
headers: { "User-Agent": USER_AGENT },
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||||
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -174,21 +174,10 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "ollama": {
|
case "ollama": {
|
||||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
const ollama = createOllama({
|
||||||
// OLLAMA_API_KEY when the URL is also from server config.
|
baseURL: baseUrl || "http://localhost:11434",
|
||||||
const ollamaApiKey = baseUrl
|
|
||||||
? apiKey || undefined
|
|
||||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
|
||||||
const ollamaProvider = createOllama({
|
|
||||||
baseURL:
|
|
||||||
baseUrl ||
|
|
||||||
process.env.OLLAMA_BASE_URL ||
|
|
||||||
"https://ollama.com/api",
|
|
||||||
...(ollamaApiKey && {
|
|
||||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
model = ollamaProvider(modelId)
|
model = ollama(modelId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Cloud } from "lucide-react"
|
import { Cloud } from "lucide-react"
|
||||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -70,45 +69,9 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|||||||
export const ModelSelectorList = ({
|
export const ModelSelectorList = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ModelSelectorListProps) => {
|
}: ModelSelectorListProps) => (
|
||||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
|
||||||
const [showShadow, setShowShadow] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listElement = listRef.current
|
|
||||||
if (!listElement) return
|
|
||||||
|
|
||||||
const checkScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
|
||||||
// Show shadow if there is more content below
|
|
||||||
// Using a small threshold to handle fractional pixel rendering
|
|
||||||
setShowShadow(
|
|
||||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll()
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
listElement.addEventListener("scroll", checkScroll)
|
|
||||||
window.addEventListener("resize", checkScroll)
|
|
||||||
|
|
||||||
// Observe content changes (e.g. async loading of items)
|
|
||||||
const observer = new MutationObserver(checkScroll)
|
|
||||||
observer.observe(listElement, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
listElement.removeEventListener("scroll", checkScroll)
|
|
||||||
window.removeEventListener("resize", checkScroll)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CommandList
|
<CommandList
|
||||||
ref={listRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
// Hide scrollbar on all platforms
|
// Hide scrollbar on all platforms
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
@@ -117,15 +80,9 @@ export const ModelSelectorList = ({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
{/* Bottom shadow indicator for scrollable content */}
|
||||||
<div
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
|
||||||
showShadow ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ interface ChatInputProps {
|
|||||||
models?: FlattenedModel[]
|
models?: FlattenedModel[]
|
||||||
selectedModelId?: string
|
selectedModelId?: string
|
||||||
onModelSelect?: (modelId: string | undefined) => void
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
onConfigureModels?: () => void
|
|
||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
// Focus control props
|
// Focus control props
|
||||||
shouldFocus?: boolean
|
shouldFocus?: boolean
|
||||||
@@ -196,7 +195,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
models = [],
|
models = [],
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onModelSelect = () => {},
|
onModelSelect = () => {},
|
||||||
onConfigureModels,
|
|
||||||
showUnvalidatedModels = false,
|
showUnvalidatedModels = false,
|
||||||
shouldFocus = false,
|
shouldFocus = false,
|
||||||
onFocused,
|
onFocused,
|
||||||
@@ -553,7 +551,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
models={models}
|
models={models}
|
||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
onSelect={onModelSelect}
|
onSelect={onModelSelect}
|
||||||
onConfigure={onConfigureModels}
|
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
showUnvalidatedModels={showUnvalidatedModels}
|
showUnvalidatedModels={showUnvalidatedModels}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1397,7 +1397,6 @@ export default function ChatPanel({
|
|||||||
models={modelConfig.models}
|
models={modelConfig.models}
|
||||||
selectedModelId={modelConfig.selectedModelId}
|
selectedModelId={modelConfig.selectedModelId}
|
||||||
onModelSelect={modelConfig.setSelectedModelId}
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
|
||||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||||
shouldFocus={shouldFocusInput}
|
shouldFocus={shouldFocusInput}
|
||||||
onFocused={() => setShouldFocusInput(false)}
|
onFocused={() => setShouldFocusInput(false)}
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ export function ModelConfigDialog({
|
|||||||
// Check credentials based on provider type
|
// Check credentials based on provider type
|
||||||
const isBedrock = selectedProvider.provider === "bedrock"
|
const isBedrock = selectedProvider.provider === "bedrock"
|
||||||
const isEdgeOne = selectedProvider.provider === "edgeone"
|
const isEdgeOne = selectedProvider.provider === "edgeone"
|
||||||
const isOllama = selectedProvider.provider === "ollama"
|
|
||||||
const isVertexAI = selectedProvider.provider === "vertexai"
|
const isVertexAI = selectedProvider.provider === "vertexai"
|
||||||
if (isBedrock) {
|
if (isBedrock) {
|
||||||
if (
|
if (
|
||||||
@@ -297,7 +296,7 @@ export function ModelConfigDialog({
|
|||||||
if (!selectedProvider.vertexApiKey) {
|
if (!selectedProvider.vertexApiKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (!isEdgeOne && !isOllama && !selectedProvider.apiKey) {
|
} else if (!isEdgeOne && !selectedProvider.apiKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1030,8 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : selectedProvider.provider ===
|
) : selectedProvider.provider ===
|
||||||
|
"ollama" ||
|
||||||
|
selectedProvider.provider ===
|
||||||
"edgeone" ? (
|
"edgeone" ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1099,9 +1100,6 @@ export function ModelConfigDialog({
|
|||||||
dict.modelConfig
|
dict.modelConfig
|
||||||
.apiKey
|
.apiKey
|
||||||
}
|
}
|
||||||
{selectedProvider.provider ===
|
|
||||||
"ollama" &&
|
|
||||||
` ${dict.modelConfig.optional}`}
|
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -1165,9 +1163,7 @@ export function ModelConfigDialog({
|
|||||||
handleValidate
|
handleValidate
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedProvider.provider !==
|
!selectedProvider.apiKey ||
|
||||||
"ollama" &&
|
|
||||||
!selectedProvider.apiKey) ||
|
|
||||||
validationStatus ===
|
validationStatus ===
|
||||||
"validating"
|
"validating"
|
||||||
}
|
}
|
||||||
@@ -1665,16 +1661,12 @@ export function ModelConfigDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="show-unvalidated-models"
|
|
||||||
checked={modelConfig.showUnvalidatedModels}
|
checked={modelConfig.showUnvalidatedModels}
|
||||||
onCheckedChange={
|
onCheckedChange={
|
||||||
modelConfig.setShowUnvalidatedModels
|
modelConfig.setShowUnvalidatedModels
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label className="text-xs text-muted-foreground cursor-pointer">
|
||||||
htmlFor="show-unvalidated-models"
|
|
||||||
className="text-xs text-muted-foreground cursor-pointer"
|
|
||||||
>
|
|
||||||
{dict.modelConfig.showUnvalidatedModels}
|
{dict.modelConfig.showUnvalidatedModels}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Monitor,
|
Monitor,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
@@ -34,7 +33,6 @@ interface ModelSelectorProps {
|
|||||||
models: FlattenedModel[]
|
models: FlattenedModel[]
|
||||||
selectedModelId: string | undefined
|
selectedModelId: string | undefined
|
||||||
onSelect: (modelId: string | undefined) => void
|
onSelect: (modelId: string | undefined) => void
|
||||||
onConfigure?: () => void
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
}
|
}
|
||||||
@@ -85,7 +83,6 @@ export function ModelSelector({
|
|||||||
models,
|
models,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onConfigure,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showUnvalidatedModels = false,
|
showUnvalidatedModels = false,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
@@ -212,12 +209,9 @@ export function ModelSelector({
|
|||||||
<ModelSelectorInput
|
<ModelSelectorInput
|
||||||
placeholder={dict.modelConfig.searchModels}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<ModelSelectorEmpty>
|
<ModelSelectorEmpty>
|
||||||
{displayModels.length === 0 &&
|
{displayModels.length === 0 && models.length > 0
|
||||||
models.length > 0
|
|
||||||
? dict.modelConfig.noVerifiedModels
|
? dict.modelConfig.noVerifiedModels
|
||||||
: dict.modelConfig.noModelsFound}
|
: dict.modelConfig.noModelsFound}
|
||||||
</ModelSelectorEmpty>
|
</ModelSelectorEmpty>
|
||||||
@@ -256,36 +250,24 @@ export function ModelSelector({
|
|||||||
<>
|
<>
|
||||||
<ModelSelectorSectionHeader
|
<ModelSelectorSectionHeader
|
||||||
icon={<Monitor />}
|
icon={<Monitor />}
|
||||||
label={
|
label={dict.modelConfig.serverModels}
|
||||||
dict.modelConfig.serverModels
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{Array.from(
|
{Array.from(groupedServerModels.entries()).map(
|
||||||
groupedServerModels.entries(),
|
|
||||||
).map(
|
|
||||||
([
|
([
|
||||||
providerLabel,
|
providerLabel,
|
||||||
{
|
{ provider, models: providerModels },
|
||||||
provider,
|
|
||||||
models: providerModels,
|
|
||||||
},
|
|
||||||
]) => (
|
]) => (
|
||||||
<ModelSelectorGroup
|
<ModelSelectorGroup
|
||||||
key={`server-${providerLabel}`}
|
key={`server-${providerLabel}`}
|
||||||
heading={providerLabel}
|
heading={providerLabel}
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
>
|
>
|
||||||
{providerModels.map(
|
{providerModels.map((model) => (
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
value={
|
value={model.modelId}
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleSelect(
|
handleSelect(model.id)
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -302,35 +284,29 @@ export function ModelSelector({
|
|||||||
provider={
|
provider={
|
||||||
PROVIDER_LOGO_MAP[
|
PROVIDER_LOGO_MAP[
|
||||||
provider
|
provider
|
||||||
] ||
|
] || provider
|
||||||
provider
|
|
||||||
}
|
}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
{
|
{model.modelId}
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
{model.isDefault && (
|
{model.isDefault && (
|
||||||
<span
|
<span
|
||||||
title={
|
title={
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.serverDefaultModel
|
.serverDefaultModel
|
||||||
}
|
}
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.default
|
.default
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
</ModelSelectorGroup>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -347,32 +323,22 @@ export function ModelSelector({
|
|||||||
icon={<User />}
|
icon={<User />}
|
||||||
label={dict.modelConfig.userModels}
|
label={dict.modelConfig.userModels}
|
||||||
/>
|
/>
|
||||||
{Array.from(
|
{Array.from(groupedUserModels.entries()).map(
|
||||||
groupedUserModels.entries(),
|
|
||||||
).map(
|
|
||||||
([
|
([
|
||||||
providerLabel,
|
providerLabel,
|
||||||
{
|
{ provider, models: providerModels },
|
||||||
provider,
|
|
||||||
models: providerModels,
|
|
||||||
},
|
|
||||||
]) => (
|
]) => (
|
||||||
<ModelSelectorGroup
|
<ModelSelectorGroup
|
||||||
key={`user-${providerLabel}`}
|
key={`user-${providerLabel}`}
|
||||||
heading={providerLabel}
|
heading={providerLabel}
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
>
|
>
|
||||||
{providerModels.map(
|
{providerModels.map((model) => (
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
value={
|
value={model.modelId}
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleSelect(
|
handleSelect(model.id)
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -389,22 +355,18 @@ export function ModelSelector({
|
|||||||
provider={
|
provider={
|
||||||
PROVIDER_LOGO_MAP[
|
PROVIDER_LOGO_MAP[
|
||||||
provider
|
provider
|
||||||
] ||
|
] || provider
|
||||||
provider
|
|
||||||
}
|
}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
<ModelSelectorName>
|
<ModelSelectorName>
|
||||||
{
|
{model.modelId}
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
</ModelSelectorName>
|
||||||
{model.validated !==
|
{model.validated !==
|
||||||
true && (
|
true && (
|
||||||
<span
|
<span
|
||||||
title={
|
title={
|
||||||
dict
|
dict.modelConfig
|
||||||
.modelConfig
|
|
||||||
.unvalidatedModelWarning
|
.unvalidatedModelWarning
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -412,41 +374,20 @@ export function ModelSelector({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ModelSelectorItem>
|
</ModelSelectorItem>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
</ModelSelectorGroup>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ModelSelectorList>
|
|
||||||
</div>
|
{/* Info text */}
|
||||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
|
||||||
{onConfigure && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__configure_models__"
|
|
||||||
onSelect={() => {
|
|
||||||
onConfigure()
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<ModelSelectorName>
|
|
||||||
{dict.modelConfig.configureModels}
|
|
||||||
</ModelSelectorName>
|
|
||||||
</ModelSelectorItem>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
|
||||||
{showUnvalidatedModels
|
{showUnvalidatedModels
|
||||||
? dict.modelConfig.allModelsShown
|
? dict.modelConfig.allModelsShown
|
||||||
: dict.modelConfig.onlyVerifiedShown}
|
: dict.modelConfig.onlyVerifiedShown}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModelSelectorList>
|
||||||
</div>
|
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelectorRoot>
|
</ModelSelectorRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,13 +77,12 @@ function CommandInput({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
function CommandList({
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
...props
|
||||||
>(({ className, ...props }, ref) => {
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
return (
|
return (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
|
||||||
data-slot="command-list"
|
data-slot="command-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
@@ -92,8 +91,7 @@ const CommandList = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
|
|
||||||
|
|
||||||
function CommandEmpty({
|
function CommandEmpty({
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,367 +0,0 @@
|
|||||||
# material_design
|
|
||||||
|
|
||||||
**Type:** SVG images (Google Material Icons CDN)
|
|
||||||
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `{icon_name}` with any icon name from the list below.
|
|
||||||
|
|
||||||
## action (115)
|
|
||||||
|
|
||||||
- `account_balance`
|
|
||||||
- `account_balance_wallet`
|
|
||||||
- `account_box`
|
|
||||||
- `account_circle`
|
|
||||||
- `add_shopping_cart`
|
|
||||||
- `admin_panel_settings`
|
|
||||||
- `analytics`
|
|
||||||
- `arrow_right_alt`
|
|
||||||
- `article`
|
|
||||||
- `assessment`
|
|
||||||
- `assignment`
|
|
||||||
- `assignment_ind`
|
|
||||||
- `assignment_turned_in`
|
|
||||||
- `autorenew`
|
|
||||||
- `bookmark`
|
|
||||||
- `bookmark_border`
|
|
||||||
- `build`
|
|
||||||
- `calendar_month`
|
|
||||||
- `calendar_today`
|
|
||||||
- `card_giftcard`
|
|
||||||
- `check_circle`
|
|
||||||
- `check_circle_outline`
|
|
||||||
- `code`
|
|
||||||
- `contact_support`
|
|
||||||
- `credit_card`
|
|
||||||
- `dashboard`
|
|
||||||
- `date_range`
|
|
||||||
- `delete`
|
|
||||||
- `delete_forever`
|
|
||||||
- `delete_outline`
|
|
||||||
- `description`
|
|
||||||
- `dns`
|
|
||||||
- `done`
|
|
||||||
- `done_all`
|
|
||||||
- `done_outline`
|
|
||||||
- `drag_indicator`
|
|
||||||
- `event`
|
|
||||||
- `exit_to_app`
|
|
||||||
- `explore`
|
|
||||||
- `face`
|
|
||||||
- `fact_check`
|
|
||||||
- `favorite`
|
|
||||||
- `favorite_border`
|
|
||||||
- `feedback`
|
|
||||||
- `filter_alt`
|
|
||||||
- `fingerprint`
|
|
||||||
- `flight_takeoff`
|
|
||||||
- `grade`
|
|
||||||
- `help`
|
|
||||||
- `help_outline`
|
|
||||||
- `highlight_off`
|
|
||||||
- `history`
|
|
||||||
- `home`
|
|
||||||
- `info`
|
|
||||||
- `label`
|
|
||||||
- `language`
|
|
||||||
- `launch`
|
|
||||||
- `leaderboard`
|
|
||||||
- `lightbulb`
|
|
||||||
- `list`
|
|
||||||
- `lock`
|
|
||||||
- `lock_open`
|
|
||||||
- `login`
|
|
||||||
- `logout`
|
|
||||||
- `manage_accounts`
|
|
||||||
- `note_add`
|
|
||||||
- `open_in_full`
|
|
||||||
- `open_in_new`
|
|
||||||
- `paid`
|
|
||||||
- `payment`
|
|
||||||
- `pending`
|
|
||||||
- `pending_actions`
|
|
||||||
- `perm_identity`
|
|
||||||
- `pets`
|
|
||||||
- `power_settings_new`
|
|
||||||
- `preview`
|
|
||||||
- `print`
|
|
||||||
- `published_with_changes`
|
|
||||||
- `question_answer`
|
|
||||||
- `receipt`
|
|
||||||
- `reorder`
|
|
||||||
- `report_problem`
|
|
||||||
- `room`
|
|
||||||
- `savings`
|
|
||||||
- `schedule`
|
|
||||||
- `search`
|
|
||||||
- `settings`
|
|
||||||
- `shopping_bag`
|
|
||||||
- `shopping_basket`
|
|
||||||
- `shopping_cart`
|
|
||||||
- `star_rate`
|
|
||||||
- `stars`
|
|
||||||
- `store`
|
|
||||||
- `supervisor_account`
|
|
||||||
- `swap_horiz`
|
|
||||||
- `sync_alt`
|
|
||||||
- `task_alt`
|
|
||||||
- `thumb_up`
|
|
||||||
- `thumb_up_off_alt`
|
|
||||||
- `timeline`
|
|
||||||
- `tips_and_updates`
|
|
||||||
- `today`
|
|
||||||
- `touch_app`
|
|
||||||
- `trending_up`
|
|
||||||
- `update`
|
|
||||||
- `verified`
|
|
||||||
- `verified_user`
|
|
||||||
- `view_in_ar`
|
|
||||||
- `view_list`
|
|
||||||
- `visibility`
|
|
||||||
- `visibility_off`
|
|
||||||
- `watch_later`
|
|
||||||
- `work`
|
|
||||||
- `work_outline`
|
|
||||||
- `zoom_in`
|
|
||||||
|
|
||||||
## alert (4)
|
|
||||||
|
|
||||||
- `error`
|
|
||||||
- `error_outline`
|
|
||||||
- `warning`
|
|
||||||
- `warning_amber`
|
|
||||||
|
|
||||||
## av (12)
|
|
||||||
|
|
||||||
- `library_books`
|
|
||||||
- `mic`
|
|
||||||
- `pause`
|
|
||||||
- `play_arrow`
|
|
||||||
- `play_circle`
|
|
||||||
- `play_circle_filled`
|
|
||||||
- `play_circle_outline`
|
|
||||||
- `replay`
|
|
||||||
- `skip_next`
|
|
||||||
- `videocam`
|
|
||||||
- `volume_off`
|
|
||||||
- `volume_up`
|
|
||||||
|
|
||||||
## communication (13)
|
|
||||||
|
|
||||||
- `alternate_email`
|
|
||||||
- `business`
|
|
||||||
- `call`
|
|
||||||
- `chat`
|
|
||||||
- `chat_bubble_outline`
|
|
||||||
- `email`
|
|
||||||
- `forum`
|
|
||||||
- `list_alt`
|
|
||||||
- `location_on`
|
|
||||||
- `mail_outline`
|
|
||||||
- `phone`
|
|
||||||
- `qr_code_scanner`
|
|
||||||
- `vpn_key`
|
|
||||||
|
|
||||||
## content (27)
|
|
||||||
|
|
||||||
- `add`
|
|
||||||
- `add_box`
|
|
||||||
- `add_circle`
|
|
||||||
- `add_circle_outline`
|
|
||||||
- `block`
|
|
||||||
- `bolt`
|
|
||||||
- `calculate`
|
|
||||||
- `clear`
|
|
||||||
- `content_copy`
|
|
||||||
- `create`
|
|
||||||
- `filter_list`
|
|
||||||
- `flag`
|
|
||||||
- `how_to_reg`
|
|
||||||
- `insights`
|
|
||||||
- `inventory`
|
|
||||||
- `inventory_2`
|
|
||||||
- `link`
|
|
||||||
- `mail`
|
|
||||||
- `push_pin`
|
|
||||||
- `remove`
|
|
||||||
- `remove_circle`
|
|
||||||
- `remove_circle_outline`
|
|
||||||
- `reply`
|
|
||||||
- `save`
|
|
||||||
- `send`
|
|
||||||
- `sort`
|
|
||||||
- `undo`
|
|
||||||
|
|
||||||
## device (9)
|
|
||||||
|
|
||||||
- `dark_mode`
|
|
||||||
- `devices`
|
|
||||||
- `light_mode`
|
|
||||||
- `password`
|
|
||||||
- `restart_alt`
|
|
||||||
- `sell`
|
|
||||||
- `signal_cellular_alt`
|
|
||||||
- `summarize`
|
|
||||||
- `task`
|
|
||||||
|
|
||||||
## editor (9)
|
|
||||||
|
|
||||||
- `attach_file`
|
|
||||||
- `attach_money`
|
|
||||||
- `bar_chart`
|
|
||||||
- `checklist`
|
|
||||||
- `edit_note`
|
|
||||||
- `format_list_bulleted`
|
|
||||||
- `mode_edit`
|
|
||||||
- `monetization_on`
|
|
||||||
- `post_add`
|
|
||||||
|
|
||||||
## file (8)
|
|
||||||
|
|
||||||
- `cloud_upload`
|
|
||||||
- `download`
|
|
||||||
- `file_download`
|
|
||||||
- `file_upload`
|
|
||||||
- `folder`
|
|
||||||
- `folder_open`
|
|
||||||
- `grid_view`
|
|
||||||
- `upload_file`
|
|
||||||
|
|
||||||
## hardware (6)
|
|
||||||
|
|
||||||
- `computer`
|
|
||||||
- `keyboard_arrow_down`
|
|
||||||
- `keyboard_arrow_right`
|
|
||||||
- `phone_iphone`
|
|
||||||
- `security`
|
|
||||||
- `smartphone`
|
|
||||||
|
|
||||||
## image (16)
|
|
||||||
|
|
||||||
- `add_a_photo`
|
|
||||||
- `auto_awesome`
|
|
||||||
- `auto_stories`
|
|
||||||
- `circle`
|
|
||||||
- `collections`
|
|
||||||
- `edit`
|
|
||||||
- `image`
|
|
||||||
- `navigate_before`
|
|
||||||
- `navigate_next`
|
|
||||||
- `palette`
|
|
||||||
- `photo_camera`
|
|
||||||
- `picture_as_pdf`
|
|
||||||
- `receipt_long`
|
|
||||||
- `remove_red_eye`
|
|
||||||
- `timer`
|
|
||||||
- `tune`
|
|
||||||
|
|
||||||
## maps (11)
|
|
||||||
|
|
||||||
- `badge`
|
|
||||||
- `category`
|
|
||||||
- `directions_car`
|
|
||||||
- `local_fire_department`
|
|
||||||
- `local_offer`
|
|
||||||
- `local_shipping`
|
|
||||||
- `map`
|
|
||||||
- `menu_book`
|
|
||||||
- `place`
|
|
||||||
- `restaurant`
|
|
||||||
- `volunteer_activism`
|
|
||||||
|
|
||||||
## navigation (29)
|
|
||||||
|
|
||||||
- `apps`
|
|
||||||
- `arrow_back`
|
|
||||||
- `arrow_back_ios`
|
|
||||||
- `arrow_back_ios_new`
|
|
||||||
- `arrow_downward`
|
|
||||||
- `arrow_drop_down`
|
|
||||||
- `arrow_drop_up`
|
|
||||||
- `arrow_forward`
|
|
||||||
- `arrow_forward_ios`
|
|
||||||
- `arrow_right`
|
|
||||||
- `arrow_upward`
|
|
||||||
- `campaign`
|
|
||||||
- `cancel`
|
|
||||||
- `check`
|
|
||||||
- `chevron_left`
|
|
||||||
- `chevron_right`
|
|
||||||
- `close`
|
|
||||||
- `double_arrow`
|
|
||||||
- `east`
|
|
||||||
- `expand_less`
|
|
||||||
- `expand_more`
|
|
||||||
- `fullscreen`
|
|
||||||
- `menu`
|
|
||||||
- `menu_open`
|
|
||||||
- `more_horiz`
|
|
||||||
- `more_vert`
|
|
||||||
- `payments`
|
|
||||||
- `refresh`
|
|
||||||
- `unfold_more`
|
|
||||||
|
|
||||||
## notification (6)
|
|
||||||
|
|
||||||
- `account_tree`
|
|
||||||
- `event_available`
|
|
||||||
- `priority_high`
|
|
||||||
- `support_agent`
|
|
||||||
- `sync`
|
|
||||||
- `wifi`
|
|
||||||
|
|
||||||
## places (2)
|
|
||||||
|
|
||||||
- `apartment`
|
|
||||||
- `storefront`
|
|
||||||
|
|
||||||
## search (2)
|
|
||||||
|
|
||||||
- `feed`
|
|
||||||
- `manage_search`
|
|
||||||
|
|
||||||
## social (23)
|
|
||||||
|
|
||||||
- `construction`
|
|
||||||
- `emoji_emotions`
|
|
||||||
- `emoji_events`
|
|
||||||
- `engineering`
|
|
||||||
- `group`
|
|
||||||
- `group_add`
|
|
||||||
- `groups`
|
|
||||||
- `health_and_safety`
|
|
||||||
- `notifications`
|
|
||||||
- `notifications_active`
|
|
||||||
- `notifications_none`
|
|
||||||
- `people`
|
|
||||||
- `people_alt`
|
|
||||||
- `person`
|
|
||||||
- `person_add`
|
|
||||||
- `person_outline`
|
|
||||||
- `psychology`
|
|
||||||
- `public`
|
|
||||||
- `school`
|
|
||||||
- `share`
|
|
||||||
- `thumb_up_alt`
|
|
||||||
- `travel_explore`
|
|
||||||
- `water_drop`
|
|
||||||
|
|
||||||
## toggle (8)
|
|
||||||
|
|
||||||
- `check_box`
|
|
||||||
- `check_box_outline_blank`
|
|
||||||
- `radio_button_checked`
|
|
||||||
- `radio_button_unchecked`
|
|
||||||
- `star`
|
|
||||||
- `star_border`
|
|
||||||
- `star_outline`
|
|
||||||
- `toggle_on`
|
|
||||||
|
|
||||||
Total: 300 icons (top by popularity from 2100+ available)
|
|
||||||
@@ -359,9 +359,9 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
baseUrl: "MODELSCOPE_BASE_URL",
|
||||||
},
|
},
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock doesn't use API keys in the same way
|
// bedrock and ollama don't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ if (!gotTheLock) {
|
|||||||
if (
|
if (
|
||||||
url.includes("diagrams.net") ||
|
url.includes("diagrams.net") ||
|
||||||
url.includes("draw.io") ||
|
url.includes("draw.io") ||
|
||||||
url.startsWith("http://localhost") ||
|
url.startsWith("http://localhost")
|
||||||
url.startsWith("http://127.0.0.1")
|
|
||||||
) {
|
) {
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
|
|||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "127.0.0.1",
|
HOSTNAME: "localhost",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import { app } from "electron"
|
|||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Legacy production port — tried first to preserve localStorage for existing users
|
// Production mode uses fixed port (61337) to preserve localStorage
|
||||||
legacyProduction: 61337,
|
// Falls back to sequential ports if unavailable
|
||||||
// New production port below the ephemeral range (49152-65535)
|
production: 61337,
|
||||||
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
|
||||||
production: 13370,
|
|
||||||
// Maximum attempts to find an available port (fallback)
|
// Maximum attempts to find an available port (fallback)
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
@@ -29,10 +27,7 @@ let allocatedPort: number | null = null
|
|||||||
export function isPortAvailable(port: number): Promise<boolean> {
|
export function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
server.once("error", () => resolve(false))
|
||||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -44,12 +39,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses fixed port (13370) to preserve localStorage
|
* - In production: uses fixed port (61337) to preserve localStorage
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
* - Last resort: lets the OS assign a port (port 0)
|
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
|
* @throws Error if no available port found after max attempts
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, try legacy port first to preserve existing users' localStorage
|
// Try preferred port first
|
||||||
if (!isDev) {
|
|
||||||
const legacyPort = PORT_CONFIG.legacyProduction
|
|
||||||
if (await isPortAvailable(legacyPort)) {
|
|
||||||
allocatedPort = legacyPort
|
|
||||||
return legacyPort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try preferred port
|
|
||||||
if (await isPortAvailable(preferredPort)) {
|
if (await isPortAvailable(preferredPort)) {
|
||||||
allocatedPort = preferredPort
|
allocatedPort = preferredPort
|
||||||
return preferredPort
|
return preferredPort
|
||||||
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: let the OS pick an available port
|
throw new Error(
|
||||||
console.warn(
|
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||||
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
|
||||||
)
|
)
|
||||||
const osPort = await new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.once("listening", () => {
|
|
||||||
const addr = server.address()
|
|
||||||
const port = (addr as net.AddressInfo).port
|
|
||||||
server.close(() => resolve(port))
|
|
||||||
})
|
|
||||||
server.listen(0, "127.0.0.1")
|
|
||||||
})
|
|
||||||
allocatedPort = osPort
|
|
||||||
console.log(`OS assigned port: ${osPort}`)
|
|
||||||
return osPort
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://127.0.0.1:${allocatedPort}`
|
return `http://localhost:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
|
|
||||||
// Handle page title updates
|
// Handle page title updates
|
||||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||||
if (
|
if (title && !title.includes("localhost")) {
|
||||||
title &&
|
|
||||||
!title.includes("localhost") &&
|
|
||||||
!title.includes("127.0.0.1")
|
|
||||||
) {
|
|
||||||
mainWindow?.setTitle(title)
|
mainWindow?.setTitle(title)
|
||||||
} else {
|
} else {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -59,9 +59,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
|
||||||
# AZURE_REASONING_SUMMARY=detailed
|
# AZURE_REASONING_SUMMARY=detailed
|
||||||
|
|
||||||
# Ollama Configuration (Local or Cloud)
|
# Ollama (Local) Configuration
|
||||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
|
||||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
|
||||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||||
|
|
||||||
# OpenRouter Configuration
|
# OpenRouter Configuration
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ export interface ClientOverrides {
|
|||||||
vertexApiKey?: string | null // Express Mode API key
|
vertexApiKey?: string | null // Express Mode API key
|
||||||
// Custom headers (e.g., for EdgeOne cookie auth)
|
// Custom headers (e.g., for EdgeOne cookie auth)
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
// Custom env var name(s) for server models
|
// Custom env var names for server models (allows multiple API keys per provider)
|
||||||
// Can be a single string or array of strings for load balancing
|
apiKeyEnv?: string
|
||||||
apiKeyEnv?: string | string[]
|
|
||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +99,10 @@ export function resolveBaseURL(
|
|||||||
/**
|
/**
|
||||||
* Resolve API key from custom env var name or default env var.
|
* Resolve API key from custom env var name or default env var.
|
||||||
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
* Supports multiple API keys per provider via ai-models.json apiKeyEnv config.
|
||||||
* When multiple keys are configured, randomly selects one for load balancing.
|
|
||||||
*
|
*
|
||||||
* Priority:
|
* Priority:
|
||||||
* 1. User-provided API key (overrides.apiKey)
|
* 1. User-provided API key (overrides.apiKey)
|
||||||
* 2. Custom env var(s) from ai-models.json (overrides.apiKeyEnv)
|
* 2. Custom env var from ai-models.json (overrides.apiKeyEnv)
|
||||||
* - If array, randomly picks one with a valid value
|
|
||||||
* 3. Default provider env var (defaultEnvVar)
|
* 3. Default provider env var (defaultEnvVar)
|
||||||
*/
|
*/
|
||||||
function resolveApiKey(
|
function resolveApiKey(
|
||||||
@@ -113,30 +110,7 @@ function resolveApiKey(
|
|||||||
defaultEnvVar: string,
|
defaultEnvVar: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (overrides?.apiKey) return overrides.apiKey
|
if (overrides?.apiKey) return overrides.apiKey
|
||||||
|
if (overrides?.apiKeyEnv) return process.env[overrides.apiKeyEnv]
|
||||||
if (overrides?.apiKeyEnv) {
|
|
||||||
// Handle array of env var names - randomly select one
|
|
||||||
if (Array.isArray(overrides.apiKeyEnv)) {
|
|
||||||
// Filter to only env vars that have values
|
|
||||||
const validEnvVars = overrides.apiKeyEnv.filter(
|
|
||||||
(envVar) => process.env[envVar],
|
|
||||||
)
|
|
||||||
if (validEnvVars.length > 0) {
|
|
||||||
// Randomly select one
|
|
||||||
const selectedEnvVar =
|
|
||||||
validEnvVars[
|
|
||||||
Math.floor(Math.random() * validEnvVars.length)
|
|
||||||
]
|
|
||||||
console.log(
|
|
||||||
`[API Key Routing] Selected ${selectedEnvVar} from ${validEnvVars.length} available keys`,
|
|
||||||
)
|
|
||||||
return process.env[selectedEnvVar]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return process.env[overrides.apiKeyEnv]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.env[defaultEnvVar]
|
return process.env[defaultEnvVar]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,24 +516,12 @@ function detectProvider(): ProviderName | null {
|
|||||||
/**
|
/**
|
||||||
* Validate that required API keys are present for the selected provider
|
* Validate that required API keys are present for the selected provider
|
||||||
* @param provider - The provider to validate
|
* @param provider - The provider to validate
|
||||||
* @param customApiKeyEnv - Optional custom env var name(s) (from ai-models.json apiKeyEnv)
|
* @param customApiKeyEnv - Optional custom env var name (from ai-models.json apiKeyEnv)
|
||||||
*/
|
*/
|
||||||
function validateProviderCredentials(
|
function validateProviderCredentials(
|
||||||
provider: ProviderName,
|
provider: ProviderName,
|
||||||
customApiKeyEnv?: string | string[],
|
customApiKeyEnv?: string,
|
||||||
): void {
|
): void {
|
||||||
// Handle array of env var names - at least one must be set
|
|
||||||
if (Array.isArray(customApiKeyEnv)) {
|
|
||||||
const hasAnyKey = customApiKeyEnv.some((envVar) => process.env[envVar])
|
|
||||||
if (!hasAnyKey) {
|
|
||||||
throw new Error(
|
|
||||||
`At least one of [${customApiKeyEnv.join(", ")}] environment variables is required for ${provider} provider. ` +
|
|
||||||
`Please set at least one in your .env.local file.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use custom env var name if provided, otherwise use default
|
// Use custom env var name if provided, otherwise use default
|
||||||
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
const requiredVar = customApiKeyEnv || PROVIDER_ENV_VARS[provider]
|
||||||
if (requiredVar && !process.env[requiredVar]) {
|
if (requiredVar && !process.env[requiredVar]) {
|
||||||
@@ -596,7 +558,7 @@ function validateProviderCredentials(
|
|||||||
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
|
* - GOOGLE_GENERATIVE_AI_API_KEY: Google API key
|
||||||
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
|
* - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials
|
||||||
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
|
||||||
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
|
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to http://localhost:11434)
|
||||||
* - OPENROUTER_API_KEY: OpenRouter API key
|
* - OPENROUTER_API_KEY: OpenRouter API key
|
||||||
* - DEEPSEEK_API_KEY: DeepSeek API key
|
* - DEEPSEEK_API_KEY: DeepSeek API key
|
||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
@@ -611,15 +573,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||||
// Exception: EdgeOne doesn't require API keys.
|
// Exception: EdgeOne and Ollama providers don't require API keys
|
||||||
// Ollama is exempt only when no server OLLAMA_API_KEY is configured;
|
|
||||||
// when it IS configured, the outer guard also enforces client apiKey for custom baseUrls.
|
|
||||||
if (
|
if (
|
||||||
overrides?.baseUrl &&
|
overrides?.baseUrl &&
|
||||||
!overrides?.apiKey &&
|
!overrides?.apiKey &&
|
||||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||||
overrides?.provider !== "edgeone" &&
|
overrides?.provider !== "edgeone" &&
|
||||||
!(overrides?.provider === "ollama" && !process.env.OLLAMA_API_KEY)
|
overrides?.provider !== "ollama"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`API key is required when using a custom base URL. ` +
|
`API key is required when using a custom base URL. ` +
|
||||||
@@ -880,19 +840,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
case "ollama": {
|
case "ollama": {
|
||||||
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
||||||
// SECURITY: When client provides a custom base URL, only use
|
if (baseURL) {
|
||||||
// client-provided API key. Never fall back to server OLLAMA_API_KEY
|
const customOllama = createOllama({ baseURL })
|
||||||
// to prevent leaking server credentials to user-controlled endpoints.
|
|
||||||
const apiKey = overrides?.baseUrl
|
|
||||||
? overrides?.apiKey || undefined
|
|
||||||
: resolveApiKey(overrides, "OLLAMA_API_KEY")
|
|
||||||
if (baseURL || apiKey) {
|
|
||||||
const customOllama = createOllama({
|
|
||||||
...(baseURL && { baseURL }),
|
|
||||||
...(apiKey && {
|
|
||||||
headers: { Authorization: `Bearer ${apiKey}` },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
model = customOllama(modelId)
|
model = customOllama(modelId)
|
||||||
} else {
|
} else {
|
||||||
model = ollama(modelId)
|
model = ollama(modelId)
|
||||||
@@ -1230,12 +1179,7 @@ export function supportsImageInput(modelId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Qwen text models (not vision variants like qwen-vl)
|
// Qwen text models (not vision variants like qwen-vl)
|
||||||
// qwen3.5-plus is a vision model
|
if (lowerModelId.includes("qwen") && !hasVisionIndicator) {
|
||||||
if (
|
|
||||||
lowerModelId.includes("qwen") &&
|
|
||||||
!hasVisionIndicator &&
|
|
||||||
!lowerModelId.includes("qwen3.5-plus")
|
|
||||||
) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ export const ServerProviderSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
provider: ProviderNameSchema,
|
provider: ProviderNameSchema,
|
||||||
models: z.array(z.string().min(1)),
|
models: z.array(z.string().min(1)),
|
||||||
// Optional: custom environment variable name(s) for API key
|
// Optional: custom environment variable name for API key
|
||||||
// Can be a single string or array of strings for load balancing
|
// e.g., "OPENAI_API_KEY_TEAM_A" instead of default "OPENAI_API_KEY"
|
||||||
// e.g., "OPENAI_API_KEY_TEAM_A" or ["OPENAI_KEY_1", "OPENAI_KEY_2"]
|
apiKeyEnv: z.string().min(1).optional(),
|
||||||
apiKeyEnv: z
|
|
||||||
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
||||||
.optional(),
|
|
||||||
// Optional: custom environment variable name for base URL
|
// Optional: custom environment variable name for base URL
|
||||||
baseUrlEnv: z.string().min(1).optional(),
|
baseUrlEnv: z.string().min(1).optional(),
|
||||||
// Optional: mark the first model in this provider as the default
|
// Optional: mark the first model in this provider as the default
|
||||||
@@ -39,9 +36,8 @@ export interface FlattenedServerModel {
|
|||||||
provider: ProviderName
|
provider: ProviderName
|
||||||
providerLabel: string
|
providerLabel: string
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
// Custom env var name(s) for API key (optional)
|
// Custom env var names for credentials (optional)
|
||||||
// Can be a single string or array of strings for load balancing
|
apiKeyEnv?: string
|
||||||
apiKeyEnv?: string | string[]
|
|
||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ parameters: {
|
|||||||
}
|
}
|
||||||
---Tool4---
|
---Tool4---
|
||||||
tool name: get_shape_library
|
tool name: get_shape_library
|
||||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, etc.) before creating diagrams with cloud/tech icons.
|
||||||
parameters: {
|
parameters: {
|
||||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, etc.
|
||||||
}
|
}
|
||||||
---End of tools---
|
---End of tools---
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ IMPORTANT: Choose the right tool:
|
|||||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||||
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
- Use get_shape_library for: Discovering available icons/shapes when creating cloud architecture or technical diagrams (call BEFORE display_diagram)
|
||||||
|
|
||||||
Core capabilities:
|
Core capabilities:
|
||||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||||
@@ -92,7 +92,7 @@ Note that:
|
|||||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||||
- Return XML only via tool calls, never in text responses.
|
- Return XML only via tool calls, never in text responses.
|
||||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
- For cloud/tech diagrams (AWS, Azure, GCP, K8s), call get_shape_library first to discover available icon shapes and their syntax.
|
||||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||||
|
|
||||||
When using edit_diagram tool:
|
When using edit_diagram tool:
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ export interface FlattenedModel {
|
|||||||
source?: "user" | "server"
|
source?: "user" | "server"
|
||||||
// Whether this model is the server default (matches AI_MODEL env var)
|
// Whether this model is the server default (matches AI_MODEL env var)
|
||||||
isDefault?: boolean
|
isDefault?: boolean
|
||||||
// Custom env var name(s) for server models
|
// Custom env var names for server models (allows multiple API keys per provider)
|
||||||
// Can be a single string or array of strings for load balancing
|
apiKeyEnv?: string
|
||||||
apiKeyEnv?: string | string[]
|
|
||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ export const PROVIDER_INFO: Record<
|
|||||||
bedrock: { label: "Amazon Bedrock" },
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
ollama: {
|
ollama: {
|
||||||
label: "Ollama",
|
label: "Ollama",
|
||||||
defaultBaseUrl: "https://ollama.com/api",
|
defaultBaseUrl: "http://localhost:11434",
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
@@ -264,7 +263,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||||
"Qwen/Qwen2.5-7B-Instruct",
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
"Qwen/Qwen2-VL-72B-Instruct",
|
"Qwen/Qwen2-VL-72B-Instruct",
|
||||||
"qwen3.5-plus",
|
|
||||||
],
|
],
|
||||||
sglang: [
|
sglang: [
|
||||||
// SGLang is OpenAI-compatible, models depend on deployment
|
// SGLang is OpenAI-compatible, models depend on deployment
|
||||||
@@ -294,7 +292,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||||
"Qwen/Qwen3-32B",
|
"Qwen/Qwen3-32B",
|
||||||
"qwen3.5-plus",
|
|
||||||
// DeepSeek
|
// DeepSeek
|
||||||
"deepseek-ai/DeepSeek-R1-0528",
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
"deepseek-ai/DeepSeek-V3.2",
|
||||||
|
|||||||
4666
package-lock.json
generated
4666
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/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@extractus/article-extractor": "^8.0.18",
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
"@formatjs/intl-localematcher": "^0.8.0",
|
"@formatjs/intl-localematcher": "^0.7.2",
|
||||||
"@langfuse/client": "^4.4.9",
|
"@langfuse/client": "^4.4.9",
|
||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@opennextjs/cloudflare": "1.16.1",
|
"@opennextjs/cloudflare": "1.14.8",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
|||||||
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",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.1.12",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
@@ -469,9 +469,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.9",
|
"version": "1.19.7",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -481,12 +481,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.26.0",
|
"version": "1.25.2",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz",
|
||||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
"integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.9",
|
"@hono/node-server": "^1.19.7",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -494,15 +494,14 @@
|
|||||||
"cross-spawn": "^7.0.5",
|
"cross-spawn": "^7.0.5",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
"eventsource-parser": "^3.0.0",
|
"eventsource-parser": "^3.0.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.0.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^7.5.0",
|
||||||
"hono": "^4.11.4",
|
"jose": "^6.1.1",
|
||||||
"jose": "^6.1.3",
|
|
||||||
"json-schema-typed": "^8.0.2",
|
"json-schema-typed": "^8.0.2",
|
||||||
"pkce-challenge": "^5.0.0",
|
"pkce-challenge": "^5.0.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
"zod": "^3.25 || ^4.0",
|
"zod": "^3.25 || ^4.0",
|
||||||
"zod-to-json-schema": "^3.25.1"
|
"zod-to-json-schema": "^3.25.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -521,9 +520,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.12",
|
"version": "24.10.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.6.tgz",
|
||||||
"integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==",
|
"integrity": "sha512-B8h60xgJMR/xmgyX9fncRzEW9gCxoJjdenUhke2v1JGOd/V66KopmWrLPXi5oUI4VuiGK+d+HlXJjDRZMj21EQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1075,13 +1074,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.2.1",
|
"version": "7.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"ip-address": "10.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
},
|
},
|
||||||
@@ -1264,9 +1260,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.11.9",
|
"version": "4.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
|
||||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
"integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1352,15 +1348,6 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
|
||||||
"version": "10.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
|
||||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -2064,9 +2051,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -2074,9 +2061,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.25.1",
|
"version": "3.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25 || ^4"
|
"zod": "^3.25 || ^4"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.1.15",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ interface SessionState {
|
|||||||
lastUpdated: Date
|
lastUpdated: Date
|
||||||
svg?: string // Cached SVG from last browser save
|
svg?: string // Cached SVG from last browser save
|
||||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
|
||||||
exportData?: string // Base64/SVG data returned by browser after export
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stateStore = new Map<string, SessionState>()
|
export const stateStore = new Map<string, SessionState>()
|
||||||
@@ -93,8 +91,6 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
|||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
|
||||||
exportData: existing?.exportData, // Preserve export result
|
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
return newVersion
|
return newVersion
|
||||||
@@ -259,7 +255,6 @@ function handleStateApi(
|
|||||||
xml: state?.xml || null,
|
xml: state?.xml || null,
|
||||||
version: state?.version || 0,
|
version: state?.version || 0,
|
||||||
syncRequested: !!state?.syncRequested,
|
syncRequested: !!state?.syncRequested,
|
||||||
exportFormat: state?.exportFormat || null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -269,30 +264,13 @@ function handleStateApi(
|
|||||||
})
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(body)
|
const { sessionId, xml, svg } = JSON.parse(body)
|
||||||
const { sessionId } = data
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const version = setState(sessionId, xml, svg)
|
||||||
// Browser is returning export data (png/svg)
|
|
||||||
if (data.exportData !== undefined) {
|
|
||||||
const state = stateStore.get(sessionId)
|
|
||||||
if (state) {
|
|
||||||
state.exportData = data.exportData
|
|
||||||
state.exportFormat = undefined
|
|
||||||
log.debug(
|
|
||||||
`Export data received for session=${sessionId}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
|
||||||
res.end(JSON.stringify({ success: true }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = setState(sessionId, data.xml, data.svg)
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, version }))
|
res.end(JSON.stringify({ success: true, version }))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -660,7 +638,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||||
let pendingSvgExport = null;
|
let pendingSvgExport = null;
|
||||||
let pendingAiSvg = false;
|
let pendingAiSvg = false;
|
||||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||||
@@ -676,23 +653,6 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
// Fallback if export doesn't respond
|
// Fallback if export doesn't respond
|
||||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||||
} else if (msg.event === 'export' && msg.data) {
|
} else if (msg.event === 'export' && msg.data) {
|
||||||
// Handle MCP server export request (png/svg)
|
|
||||||
// Verify the response matches the requested format to avoid capturing
|
|
||||||
// unrelated exports (autosave SVG, sync XML)
|
|
||||||
if (pendingMcpExport) {
|
|
||||||
const d = msg.data;
|
|
||||||
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
|
||||||
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
|
||||||
if (isPng || isSvg) {
|
|
||||||
pendingMcpExport = null;
|
|
||||||
fetch('/api/state', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sessionId, exportData: d })
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||||
const dl = pendingDownload;
|
const dl = pendingDownload;
|
||||||
@@ -772,21 +732,11 @@ function getHtmlPage(sessionId: string): string {
|
|||||||
pendingSyncExport = true;
|
pendingSyncExport = true;
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||||
}
|
}
|
||||||
// Load new diagram from server (before export, so we export latest)
|
// Load new diagram from server
|
||||||
if (s.version > currentVersion && s.xml) {
|
if (s.version > currentVersion && s.xml) {
|
||||||
currentVersion = s.version;
|
currentVersion = s.version;
|
||||||
loadDiagram(s.xml, true);
|
loadDiagram(s.xml, true);
|
||||||
}
|
}
|
||||||
// Handle export request from MCP server (png/svg) - after version update
|
|
||||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
|
||||||
pendingMcpExport = s.exportFormat;
|
|
||||||
const exportOpts = s.exportFormat === 'png'
|
|
||||||
? { action: 'export', format: 'png', scale: 2 }
|
|
||||||
: { action: 'export', format: 'svg' };
|
|
||||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
|
||||||
// Timeout: reset if draw.io never responds
|
|
||||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,24 +546,16 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"export_diagram",
|
"export_diagram",
|
||||||
{
|
{
|
||||||
description:
|
description: "Export the current diagram to a .drawio file.",
|
||||||
"Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " +
|
|
||||||
"The format is auto-detected from the file extension, or can be specified explicitly.",
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
path: z
|
path: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"File path to save the diagram (e.g., ./diagram.drawio, ./diagram.png, ./diagram.svg)",
|
"File path to save the diagram (e.g., ./diagram.drawio)",
|
||||||
),
|
|
||||||
format: z
|
|
||||||
.enum(["drawio", "png", "svg"])
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Export format. If omitted, detected from file extension. Defaults to drawio.",
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ path, format }) => {
|
async ({ path }) => {
|
||||||
try {
|
try {
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
return {
|
return {
|
||||||
@@ -598,21 +590,16 @@ server.registerTool(
|
|||||||
const fs = await import("node:fs/promises")
|
const fs = await import("node:fs/promises")
|
||||||
const nodePath = await import("node:path")
|
const nodePath = await import("node:path")
|
||||||
|
|
||||||
// Detect format from extension if not specified
|
|
||||||
const ext = nodePath.extname(path).toLowerCase()
|
|
||||||
const detectedFormat =
|
|
||||||
format ||
|
|
||||||
(ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio")
|
|
||||||
|
|
||||||
// Original .drawio export path (unchanged logic)
|
|
||||||
if (detectedFormat === "drawio") {
|
|
||||||
let filePath = path
|
let filePath = path
|
||||||
if (!filePath.endsWith(".drawio")) {
|
if (!filePath.endsWith(".drawio")) {
|
||||||
filePath = `${filePath}.drawio`
|
filePath = `${filePath}.drawio`
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolutePath = nodePath.resolve(filePath)
|
const absolutePath = nodePath.resolve(filePath)
|
||||||
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
||||||
|
|
||||||
log.info(`Diagram exported to ${absolutePath}`)
|
log.info(`Diagram exported to ${absolutePath}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -621,87 +608,6 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// PNG or SVG: request browser to export via iframe
|
|
||||||
let filePath = path
|
|
||||||
if (ext !== `.${detectedFormat}`) {
|
|
||||||
if (ext === ".drawio" || ext === ".png" || ext === ".svg") {
|
|
||||||
filePath = filePath.slice(0, -ext.length)
|
|
||||||
}
|
|
||||||
filePath = `${filePath}.${detectedFormat}`
|
|
||||||
}
|
|
||||||
const absolutePath = nodePath.resolve(filePath)
|
|
||||||
|
|
||||||
const state = getState(currentSession.id)
|
|
||||||
if (!state) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Error: Session state not found. Is the browser open?",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.exportFormat = detectedFormat as "png" | "svg"
|
|
||||||
state.exportData = undefined
|
|
||||||
|
|
||||||
// Wait for browser to produce the export data
|
|
||||||
const timeoutMs = 10000
|
|
||||||
const start = Date.now()
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
if (state.exportData) break
|
|
||||||
await new Promise((r) => setTimeout(r, 200))
|
|
||||||
}
|
|
||||||
const exportData = state.exportData as string | undefined
|
|
||||||
state.exportData = undefined
|
|
||||||
state.exportFormat = undefined
|
|
||||||
|
|
||||||
if (!exportData) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Error: Export timed out. Make sure the browser tab is open and the diagram is loaded.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode and write
|
|
||||||
if (detectedFormat === "png") {
|
|
||||||
const base64 = exportData.replace(
|
|
||||||
/^data:image\/png;base64,/,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
await fs.writeFile(absolutePath, Buffer.from(base64, "base64"))
|
|
||||||
} else {
|
|
||||||
let svgContent = exportData
|
|
||||||
if (svgContent.startsWith("data:image/svg+xml;base64,")) {
|
|
||||||
const base64 = svgContent.replace(
|
|
||||||
/^data:image\/svg\+xml;base64,/,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
svgContent = Buffer.from(base64, "base64").toString("utf-8")
|
|
||||||
}
|
|
||||||
await fs.writeFile(absolutePath, svgContent, "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
const stat = await fs.stat(absolutePath)
|
|
||||||
log.info(
|
|
||||||
`Diagram exported to ${absolutePath} (${detectedFormat}, ${stat.size} bytes)`,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nFormat: ${detectedFormat}\nSize: ${stat.size} bytes`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import {
|
import {
|
||||||
getAIModel,
|
|
||||||
resolveBaseURL,
|
resolveBaseURL,
|
||||||
supportsImageInput,
|
supportsImageInput,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
@@ -190,120 +189,3 @@ describe("supportsImageInput", () => {
|
|||||||
expect(supportsImageInput("gemini-pro")).toBe(true)
|
expect(supportsImageInput("gemini-pro")).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock("ollama-ai-provider-v2", () => {
|
|
||||||
const mockModel = { modelId: "test-model" }
|
|
||||||
const mockProviderFn = vi.fn(() => mockModel)
|
|
||||||
const mockCreateOllama = vi.fn(() => mockProviderFn)
|
|
||||||
const mockOllama = vi.fn(() => mockModel)
|
|
||||||
return { createOllama: mockCreateOllama, ollama: mockOllama }
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("Ollama API key security", () => {
|
|
||||||
let createOllamaMock: ReturnType<typeof vi.fn>
|
|
||||||
const savedEnv: Record<string, string | undefined> = {}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
savedEnv.OLLAMA_API_KEY = process.env.OLLAMA_API_KEY
|
|
||||||
savedEnv.OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL
|
|
||||||
delete process.env.OLLAMA_BASE_URL
|
|
||||||
|
|
||||||
const mod = await import("ollama-ai-provider-v2")
|
|
||||||
createOllamaMock = mod.createOllama as ReturnType<typeof vi.fn>
|
|
||||||
createOllamaMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.OLLAMA_API_KEY = savedEnv.OLLAMA_API_KEY
|
|
||||||
process.env.OLLAMA_BASE_URL = savedEnv.OLLAMA_BASE_URL
|
|
||||||
})
|
|
||||||
|
|
||||||
it("applies server OLLAMA_API_KEY when no client baseUrl is provided", () => {
|
|
||||||
process.env.OLLAMA_API_KEY = "server-secret-key"
|
|
||||||
|
|
||||||
getAIModel({ provider: "ollama", modelId: "llama2" })
|
|
||||||
|
|
||||||
expect(createOllamaMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: { Authorization: "Bearer server-secret-key" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does NOT leak server OLLAMA_API_KEY when client provides a custom baseUrl", () => {
|
|
||||||
process.env.OLLAMA_API_KEY = "server-secret-key"
|
|
||||||
|
|
||||||
// When server has OLLAMA_API_KEY, the SSRF guard rejects
|
|
||||||
// client-provided baseUrl without an apiKey outright
|
|
||||||
expect(() =>
|
|
||||||
getAIModel({
|
|
||||||
provider: "ollama",
|
|
||||||
baseUrl: "https://evil-server.com",
|
|
||||||
modelId: "llama2",
|
|
||||||
}),
|
|
||||||
).toThrow("API key is required")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses client API key when client provides both baseUrl and apiKey", () => {
|
|
||||||
process.env.OLLAMA_API_KEY = "server-secret-key"
|
|
||||||
|
|
||||||
getAIModel({
|
|
||||||
provider: "ollama",
|
|
||||||
baseUrl: "https://my-ollama.com",
|
|
||||||
apiKey: "client-key",
|
|
||||||
modelId: "llama2",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createOllamaMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
baseURL: "https://my-ollama.com",
|
|
||||||
headers: { Authorization: "Bearer client-key" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("applies both server OLLAMA_BASE_URL and OLLAMA_API_KEY when no client overrides", () => {
|
|
||||||
process.env.OLLAMA_BASE_URL = "https://cloud.ollama.com"
|
|
||||||
process.env.OLLAMA_API_KEY = "server-key"
|
|
||||||
|
|
||||||
getAIModel({ provider: "ollama", modelId: "llama2" })
|
|
||||||
|
|
||||||
expect(createOllamaMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
baseURL: "https://cloud.ollama.com",
|
|
||||||
headers: { Authorization: "Bearer server-key" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("works when OLLAMA_API_KEY is set but OLLAMA_BASE_URL is not", () => {
|
|
||||||
process.env.OLLAMA_API_KEY = "server-key"
|
|
||||||
delete process.env.OLLAMA_BASE_URL
|
|
||||||
|
|
||||||
getAIModel({ provider: "ollama", modelId: "llama2" })
|
|
||||||
|
|
||||||
expect(createOllamaMock).toHaveBeenCalledTimes(1)
|
|
||||||
const callArgs = createOllamaMock.mock.calls[0][0]
|
|
||||||
expect(callArgs).not.toHaveProperty("baseURL")
|
|
||||||
expect(callArgs).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: { Authorization: "Bearer server-key" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows client custom baseUrl without apiKey when no server OLLAMA_API_KEY", () => {
|
|
||||||
delete process.env.OLLAMA_API_KEY
|
|
||||||
|
|
||||||
getAIModel({
|
|
||||||
provider: "ollama",
|
|
||||||
baseUrl: "https://my-ollama.com",
|
|
||||||
modelId: "llama2",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(createOllamaMock).toHaveBeenCalledTimes(1)
|
|
||||||
const callArgs = createOllamaMock.mock.calls[0][0]
|
|
||||||
expect(callArgs.baseURL).toBe("https://my-ollama.com")
|
|
||||||
expect(callArgs).not.toHaveProperty("headers")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -45,72 +45,6 @@ describe("ServerModelsConfigSchema", () => {
|
|||||||
ServerModelsConfigSchema.parse(invalidConfig as any),
|
ServerModelsConfigSchema.parse(invalidConfig as any),
|
||||||
).toThrow()
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("accepts apiKeyEnv as single string", () => {
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
apiKeyEnv: "OPENAI_API_KEY_TEAM_A",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = ServerModelsConfigSchema.parse(config)
|
|
||||||
expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("accepts apiKeyEnv as array of strings for load balancing", () => {
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = ServerModelsConfigSchema.parse(config)
|
|
||||||
expect(parsed.providers[0].apiKeyEnv).toEqual([
|
|
||||||
"OPENAI_KEY_1",
|
|
||||||
"OPENAI_KEY_2",
|
|
||||||
"OPENAI_KEY_3",
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects empty array for apiKeyEnv", () => {
|
|
||||||
const config = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
apiKeyEnv: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects empty string in apiKeyEnv array", () => {
|
|
||||||
const config = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI Server",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
apiKeyEnv: ["VALID_KEY", ""],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("loadFlattenedServerModels", () => {
|
describe("loadFlattenedServerModels", () => {
|
||||||
@@ -148,24 +82,4 @@ describe("loadFlattenedServerModels", () => {
|
|||||||
expect(defaultModel.provider).toBe("openai")
|
expect(defaultModel.provider).toBe("openai")
|
||||||
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
||||||
})
|
})
|
||||||
|
|
||||||
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
|
||||||
const config: ServerModelsConfig = {
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
name: "OpenAI LoadBalanced",
|
|
||||||
provider: "openai",
|
|
||||||
models: ["gpt-4o"],
|
|
||||||
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
process.env.AI_MODELS_CONFIG = JSON.stringify(config)
|
|
||||||
process.env.AI_MODELS_CONFIG_PATH = "" // Clear file path
|
|
||||||
|
|
||||||
const models = await loadFlattenedServerModels()
|
|
||||||
|
|
||||||
expect(models.length).toBe(1)
|
|
||||||
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user