mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
7 Commits
63398d9f34
...
remove-ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e88b0711 | ||
|
|
51858dbf5d | ||
|
|
3047d19238 | ||
|
|
ed069afdea | ||
|
|
d2e5afb298 | ||
|
|
d3fb2314ee | ||
|
|
447bb30745 |
@@ -147,14 +147,13 @@ Download the native desktop app for your platform from the [Releases page](https
|
|||||||
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- **Secure API key storage**: Credentials encrypted using OS keychain
|
|
||||||
- **Configuration presets**: Save and switch between AI providers via menu
|
|
||||||
- **Native file dialogs**: Open/save `.drawio` files directly
|
- **Native file dialogs**: Open/save `.drawio` files directly
|
||||||
- **Offline capable**: Works without internet after first launch
|
- **Offline capable**: Works without internet after first launch
|
||||||
|
- **Built-in settings**: Configure AI providers directly in the app
|
||||||
|
|
||||||
**Quick Setup:**
|
**Quick Setup:**
|
||||||
1. Download and install for your platform
|
1. Download and install for your platform
|
||||||
2. Open the app → **Menu → Configuration → Manage Presets**
|
2. Click the settings icon in the chat panel
|
||||||
3. Add your AI provider credentials
|
3. Add your AI provider credentials
|
||||||
4. Start creating diagrams!
|
4. Start creating diagrams!
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
wrapWithObserve,
|
wrapWithObserve,
|
||||||
} from "@/lib/langfuse"
|
} from "@/lib/langfuse"
|
||||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 120
|
||||||
|
|
||||||
@@ -167,13 +168,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||||
|
|
||||||
// Get user IP for Langfuse tracking (hashed for privacy)
|
// Get user ID for Langfuse tracking and quota
|
||||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
const userId = getUserIdFromRequest(req)
|
||||||
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
|
||||||
const userId =
|
|
||||||
rawIp === "anonymous"
|
|
||||||
? rawIp
|
|
||||||
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
|
|
||||||
|
|
||||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||||
const validSessionId =
|
const validSessionId =
|
||||||
@@ -613,14 +609,22 @@ Operations:
|
|||||||
|
|
||||||
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
For update/add, new_xml must be a complete mxCell element including mxGeometry.
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
|
Example - Add a rectangle:
|
||||||
|
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
|
||||||
|
|
||||||
|
Example - Delete a cell:
|
||||||
|
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z
|
operation: z
|
||||||
.enum(["update", "add", "delete"])
|
.enum(["update", "add", "delete"])
|
||||||
.describe("Operation type"),
|
.describe(
|
||||||
|
"Operation to perform: add, update, or delete",
|
||||||
|
),
|
||||||
cell_id: z
|
cell_id: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient } from "@/lib/langfuse"
|
||||||
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
messageId: z.string().min(1).max(200),
|
messageId: z.string().min(1).max(200),
|
||||||
@@ -32,13 +33,8 @@ export async function POST(req: Request) {
|
|||||||
return Response.json({ success: true, logged: false })
|
return Response.json({ success: true, logged: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user IP for tracking (hashed for privacy)
|
// Get user ID for tracking
|
||||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
const userId = getUserIdFromRequest(req)
|
||||||
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
|
||||||
const userId =
|
|
||||||
rawIp === "anonymous"
|
|
||||||
? rawIp
|
|
||||||
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Find the most recent chat trace for this session to attach the score to
|
||||||
|
|||||||
@@ -66,8 +66,22 @@ export const ModelSelectorInput = ({
|
|||||||
|
|
||||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||||
|
|
||||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
export const ModelSelectorList = ({
|
||||||
<CommandList {...props} />
|
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>
|
||||||
)
|
)
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import ExamplePanel from "./chat-example-panel"
|
|||||||
import { CodeBlock } from "./code-block"
|
import { CodeBlock } from "./code-block"
|
||||||
|
|
||||||
interface DiagramOperation {
|
interface DiagramOperation {
|
||||||
type: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -53,12 +53,12 @@ function getCompleteOperations(
|
|||||||
return operations.filter(
|
return operations.filter(
|
||||||
(op) =>
|
(op) =>
|
||||||
op &&
|
op &&
|
||||||
typeof op.type === "string" &&
|
typeof op.operation === "string" &&
|
||||||
["update", "add", "delete"].includes(op.type) &&
|
["update", "add", "delete"].includes(op.operation) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
// delete doesn't need new_xml, update/add do
|
// delete doesn't need new_xml, update/add do
|
||||||
(op.type === "delete" || typeof op.new_xml === "string"),
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,20 +79,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{operations.map((op, index) => (
|
{operations.map((op, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${op.type}-${op.cell_id}-${index}`}
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
op.type === "delete"
|
op.operation === "delete"
|
||||||
? "text-red-600"
|
? "text-red-600"
|
||||||
: op.type === "add"
|
: op.operation === "add"
|
||||||
? "text-green-600"
|
? "text-green-600"
|
||||||
: "text-blue-600"
|
: "text-blue-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{op.type}
|
{op.operation}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
cell_id: {op.cell_id}
|
cell_id: {op.cell_id}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
@@ -34,7 +35,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
|
|||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||||
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import { formatXML } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||||
|
|
||||||
@@ -214,9 +215,6 @@ export default function ChatPanel({
|
|||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
// Ref to hold stop function for use in onToolCall (avoids stale closure)
|
|
||||||
const stopRef = useRef<(() => void) | null>(null)
|
|
||||||
|
|
||||||
// Ref to track consecutive auto-retry count (reset on user action)
|
// Ref to track consecutive auto-retry count (reset on user action)
|
||||||
const autoRetryCountRef = useRef(0)
|
const autoRetryCountRef = useRef(0)
|
||||||
// Ref to track continuation retry count (for truncation handling)
|
// Ref to track continuation retry count (for truncation handling)
|
||||||
@@ -239,6 +237,16 @@ export default function ChatPanel({
|
|||||||
> | null>(null)
|
> | null>(null)
|
||||||
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
|
||||||
|
|
||||||
|
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||||
|
const { handleToolCall } = useDiagramToolHandlers({
|
||||||
|
partialXmlRef,
|
||||||
|
editDiagramOriginalXmlRef,
|
||||||
|
chartXMLRef,
|
||||||
|
onDisplayChart,
|
||||||
|
onFetchChart,
|
||||||
|
onExport,
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
@@ -251,311 +259,8 @@ export default function ChatPanel({
|
|||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: getApiEndpoint("/api/chat"),
|
api: getApiEndpoint("/api/chat"),
|
||||||
}),
|
}),
|
||||||
async onToolCall({ toolCall }) {
|
onToolCall: async ({ toolCall }) => {
|
||||||
if (DEBUG) {
|
await handleToolCall({ toolCall }, addToolOutput)
|
||||||
console.log(
|
|
||||||
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolCall.toolName === "display_diagram") {
|
|
||||||
const { xml } = toolCall.input as { xml: string }
|
|
||||||
|
|
||||||
// DEBUG: Log raw input to diagnose false truncation detection
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] XML ending (last 100 chars):",
|
|
||||||
xml.slice(-100),
|
|
||||||
)
|
|
||||||
console.log("[display_diagram] XML length:", xml.length)
|
|
||||||
|
|
||||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
|
||||||
const isTruncated = !isMxCellXmlComplete(xml)
|
|
||||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
|
||||||
|
|
||||||
if (isTruncated) {
|
|
||||||
// Store the partial XML for continuation via append_diagram
|
|
||||||
partialXmlRef.current = xml
|
|
||||||
|
|
||||||
// Tell LLM to use append_diagram to continue
|
|
||||||
const partialEnding = partialXmlRef.current.slice(-500)
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
|
||||||
|
|
||||||
Your output ended with:
|
|
||||||
\`\`\`
|
|
||||||
${partialEnding}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
NEXT STEP: Call append_diagram with the continuation XML.
|
|
||||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
|
||||||
- Start from EXACTLY where you stopped
|
|
||||||
- Complete all remaining mxCell elements`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete XML received - use it directly
|
|
||||||
// (continuation is now handled via append_diagram tool)
|
|
||||||
const finalXml = xml
|
|
||||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
|
||||||
|
|
||||||
// Wrap raw XML with full mxfile structure for draw.io
|
|
||||||
const fullXml = wrapWithMxFile(finalXml)
|
|
||||||
|
|
||||||
// loadDiagram validates and returns error if invalid
|
|
||||||
const validationError = onDisplayChart(fullXml)
|
|
||||||
|
|
||||||
if (validationError) {
|
|
||||||
console.warn(
|
|
||||||
"[display_diagram] Validation error:",
|
|
||||||
validationError,
|
|
||||||
)
|
|
||||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Adding tool output with state: output-error",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `${validationError}
|
|
||||||
|
|
||||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
|
||||||
|
|
||||||
Your failed XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${finalXml}
|
|
||||||
\`\`\``,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Success - diagram will be rendered by chat-message-display
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Success! Adding tool output with state: output-available",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addToolOutput({
|
|
||||||
tool: "display_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: "Successfully displayed the diagram.",
|
|
||||||
})
|
|
||||||
if (DEBUG) {
|
|
||||||
console.log(
|
|
||||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
|
||||||
const { operations } = toolCall.input as {
|
|
||||||
operations: Array<{
|
|
||||||
type: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentXml = ""
|
|
||||||
try {
|
|
||||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
|
||||||
// This ensures we apply operations to the same base XML that streaming used
|
|
||||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
if (originalXml) {
|
|
||||||
currentXml = originalXml
|
|
||||||
} else {
|
|
||||||
// Fallback: use chartXML from ref if streaming didn't capture original
|
|
||||||
const cachedXML = chartXMLRef.current
|
|
||||||
if (cachedXML) {
|
|
||||||
currentXml = cachedXML
|
|
||||||
} else {
|
|
||||||
// Last resort: export from iframe
|
|
||||||
currentXml = await onFetchChart(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { applyDiagramOperations } = await import(
|
|
||||||
"@/lib/utils"
|
|
||||||
)
|
|
||||||
const { result: editedXml, errors } =
|
|
||||||
applyDiagramOperations(currentXml, operations)
|
|
||||||
|
|
||||||
// Check for operation errors
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const errorMessages = errors
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
|
||||||
)
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Some operations failed:\n${errorMessages}
|
|
||||||
|
|
||||||
Current diagram XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${currentXml}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please check the cell IDs and retry.`,
|
|
||||||
})
|
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadDiagram validates and returns error if invalid
|
|
||||||
const validationError = onDisplayChart(editedXml)
|
|
||||||
if (validationError) {
|
|
||||||
console.warn(
|
|
||||||
"[edit_diagram] Validation error:",
|
|
||||||
validationError,
|
|
||||||
)
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Edit produced invalid XML: ${validationError}
|
|
||||||
|
|
||||||
Current diagram XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${currentXml}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please fix the operations to avoid structural issues.`,
|
|
||||||
})
|
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onExport()
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
|
||||||
})
|
|
||||||
// Clean up the shared original XML ref
|
|
||||||
editDiagramOriginalXmlRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[edit_diagram] Failed:", error)
|
|
||||||
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
|
|
||||||
addToolOutput({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Edit failed: ${errorMessage}
|
|
||||||
|
|
||||||
Current diagram XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${currentXml || "No XML available"}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
|
||||||
})
|
|
||||||
// Clean up the shared original XML ref even on error
|
|
||||||
editDiagramOriginalXmlRef.current.delete(
|
|
||||||
toolCall.toolCallId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (toolCall.toolName === "append_diagram") {
|
|
||||||
const { xml } = toolCall.input as { xml: string }
|
|
||||||
|
|
||||||
// Detect if LLM incorrectly started fresh instead of continuing
|
|
||||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
|
||||||
const trimmed = xml.trim()
|
|
||||||
const isFreshStart =
|
|
||||||
trimmed.startsWith("<mxGraphModel") ||
|
|
||||||
trimmed.startsWith("<root") ||
|
|
||||||
trimmed.startsWith("<mxfile") ||
|
|
||||||
trimmed.startsWith('<mxCell id="0"') ||
|
|
||||||
trimmed.startsWith('<mxCell id="1"')
|
|
||||||
|
|
||||||
if (isFreshStart) {
|
|
||||||
addToolOutput({
|
|
||||||
tool: "append_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
|
||||||
|
|
||||||
Continue from EXACTLY where the partial ended:
|
|
||||||
\`\`\`
|
|
||||||
${partialXmlRef.current.slice(-500)}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Start your continuation with the NEXT character after where it stopped.`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append to accumulated XML
|
|
||||||
partialXmlRef.current += xml
|
|
||||||
|
|
||||||
// Check if XML is now complete (last mxCell is complete)
|
|
||||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
// Wrap and display the complete diagram
|
|
||||||
const finalXml = partialXmlRef.current
|
|
||||||
partialXmlRef.current = "" // Reset
|
|
||||||
|
|
||||||
const fullXml = wrapWithMxFile(finalXml)
|
|
||||||
const validationError = onDisplayChart(fullXml)
|
|
||||||
|
|
||||||
if (validationError) {
|
|
||||||
addToolOutput({
|
|
||||||
tool: "append_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `Validation error after assembly: ${validationError}
|
|
||||||
|
|
||||||
Assembled XML:
|
|
||||||
\`\`\`xml
|
|
||||||
${finalXml.substring(0, 2000)}...
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Please use display_diagram with corrected XML.`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
addToolOutput({
|
|
||||||
tool: "append_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: "Diagram assembly complete and displayed successfully.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Still incomplete - signal to continue
|
|
||||||
addToolOutput({
|
|
||||||
tool: "append_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
|
||||||
|
|
||||||
Current ending:
|
|
||||||
\`\`\`
|
|
||||||
${partialXmlRef.current.slice(-500)}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Continue from EXACTLY where you stopped.`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Handle server-side quota limit (429 response)
|
// Handle server-side quota limit (429 response)
|
||||||
@@ -719,9 +424,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update stopRef so onToolCall can access it
|
|
||||||
stopRef.current = stop
|
|
||||||
|
|
||||||
// Ref to track latest messages for unload persistence
|
// Ref to track latest messages for unload persistence
|
||||||
const messagesRef = useRef(messages)
|
const messagesRef = useRef(messages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider Details (Right Panel) */}
|
{/* Provider Details (Right Panel) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<>
|
<>
|
||||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ModelSelector({
|
|||||||
<ModelSelectorInput
|
<ModelSelectorInput
|
||||||
placeholder={dict.modelConfig.searchModels}
|
placeholder={dict.modelConfig.searchModels}
|
||||||
/>
|
/>
|
||||||
<ModelSelectorList>
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<ModelSelectorEmpty>
|
<ModelSelectorEmpty>
|
||||||
{validatedModels.length === 0 && models.length > 0
|
{validatedModels.length === 0 && models.length > 0
|
||||||
? dict.modelConfig.noVerifiedModels
|
? dict.modelConfig.noVerifiedModels
|
||||||
|
|||||||
47
electron.d.ts
vendored
47
electron.d.ts
vendored
@@ -2,29 +2,6 @@
|
|||||||
* Type declarations for Electron API exposed via preload script
|
* Type declarations for Electron API exposed via preload script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Configuration preset interface */
|
|
||||||
interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of applying a preset */
|
|
||||||
interface ApplyPresetResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
env?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
@@ -46,29 +23,7 @@ declare global {
|
|||||||
/** Save data to file via save dialog */
|
/** Save data to file via save dialog */
|
||||||
saveFile: (data: string) => Promise<boolean>
|
saveFile: (data: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
|
||||||
settingsAPI?: {
|
|
||||||
/** Get all configuration presets */
|
|
||||||
getPresets: () => Promise<ConfigPreset[]>
|
|
||||||
/** Get current preset ID */
|
|
||||||
getCurrentPresetId: () => Promise<string | null>
|
|
||||||
/** Get current preset */
|
|
||||||
getCurrentPreset: () => Promise<ConfigPreset | null>
|
|
||||||
/** Save (create or update) a preset */
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => Promise<ConfigPreset>
|
|
||||||
/** Delete a preset */
|
|
||||||
deletePreset: (id: string) => Promise<boolean>
|
|
||||||
/** Apply a preset (sets environment variables and restarts server) */
|
|
||||||
applyPreset: (id: string) => Promise<ApplyPresetResult>
|
|
||||||
/** Close settings window */
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ConfigPreset, ApplyPresetResult }
|
export {}
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
import {
|
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||||
app,
|
|
||||||
BrowserWindow,
|
|
||||||
dialog,
|
|
||||||
Menu,
|
|
||||||
type MenuItemConstructorOptions,
|
|
||||||
shell,
|
|
||||||
} from "electron"
|
|
||||||
import {
|
|
||||||
applyPresetToEnv,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPresetId,
|
|
||||||
setCurrentPreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { restartNextServer } from "./next-server"
|
|
||||||
import { showSettingsWindow } from "./settings-window"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu
|
* Build and set the application menu
|
||||||
@@ -24,13 +9,6 @@ export function buildAppMenu(): void {
|
|||||||
Menu.setApplicationMenu(menu)
|
Menu.setApplicationMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild the menu (call this when presets change)
|
|
||||||
*/
|
|
||||||
export function rebuildAppMenu(): void {
|
|
||||||
buildAppMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the menu template
|
* Get the menu template
|
||||||
*/
|
*/
|
||||||
@@ -46,15 +24,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" },
|
{ role: "about" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
|
||||||
label: "Settings...",
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "services" },
|
{ role: "services" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" },
|
{ role: "hide" },
|
||||||
@@ -69,22 +38,7 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
// File menu
|
// File menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [
|
submenu: [isMac ? { role: "close" } : { role: "quit" }],
|
||||||
...(isMac
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: "Settings",
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]),
|
|
||||||
isMac ? { role: "close" } : { role: "quit" },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
@@ -129,9 +83,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
|
||||||
template.push(buildConfigMenu())
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: "Window",
|
label: "Window",
|
||||||
@@ -172,70 +123,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
|
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Configuration menu with presets
|
|
||||||
*/
|
|
||||||
function buildConfigMenu(): MenuItemConstructorOptions {
|
|
||||||
const presets = getAllPresets()
|
|
||||||
const currentPresetId = getCurrentPresetId()
|
|
||||||
|
|
||||||
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
|
||||||
label: preset.name,
|
|
||||||
type: "radio",
|
|
||||||
checked: preset.id === currentPresetId,
|
|
||||||
click: async () => {
|
|
||||||
const previousPresetId = getCurrentPresetId()
|
|
||||||
const env = applyPresetToEnv(preset.id)
|
|
||||||
|
|
||||||
if (env) {
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to restart server:", error)
|
|
||||||
|
|
||||||
// Revert to previous preset on failure
|
|
||||||
if (previousPresetId) {
|
|
||||||
applyPresetToEnv(previousPresetId)
|
|
||||||
} else {
|
|
||||||
setCurrentPreset(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild menu to restore previous checkmark state
|
|
||||||
rebuildAppMenu()
|
|
||||||
|
|
||||||
// Show error dialog to notify user
|
|
||||||
dialog.showErrorBox(
|
|
||||||
"Configuration Error",
|
|
||||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: "Configuration",
|
|
||||||
submenu: [
|
|
||||||
...(presetItems.length > 0
|
|
||||||
? [
|
|
||||||
{ label: "Switch Preset", enabled: false },
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
...presetItems,
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label:
|
|
||||||
presetItems.length > 0
|
|
||||||
? "Manage Presets..."
|
|
||||||
: "Add Configuration Preset...",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import { app, safeStorage } from "electron"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fields that contain sensitive data and should be encrypted
|
|
||||||
*/
|
|
||||||
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefix to identify encrypted values
|
|
||||||
*/
|
|
||||||
const ENCRYPTED_PREFIX = "encrypted:"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if safeStorage encryption is available
|
|
||||||
*/
|
|
||||||
function isEncryptionAvailable(): boolean {
|
|
||||||
return safeStorage.isEncryptionAvailable()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track if we've already warned about plaintext storage
|
|
||||||
*/
|
|
||||||
let hasWarnedAboutPlaintext = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a sensitive value using safeStorage
|
|
||||||
* Warns if encryption is not available (API key stored in plaintext)
|
|
||||||
*/
|
|
||||||
function encryptValue(value: string): string {
|
|
||||||
if (!value) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
if (!hasWarnedAboutPlaintext) {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
|
||||||
"API keys will be stored in PLAINTEXT. " +
|
|
||||||
"On Linux, install gnome-keyring or similar for secure storage.",
|
|
||||||
)
|
|
||||||
hasWarnedAboutPlaintext = true
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const encrypted = safeStorage.encryptString(value)
|
|
||||||
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Encryption failed:", error)
|
|
||||||
// Fail secure: don't store if encryption fails
|
|
||||||
throw new Error(
|
|
||||||
"Failed to encrypt API key. Cannot securely store credentials.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a sensitive value using safeStorage
|
|
||||||
* Returns the original value if it's not encrypted or decryption fails
|
|
||||||
*/
|
|
||||||
function decryptValue(value: string): string {
|
|
||||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
console.warn(
|
|
||||||
"Cannot decrypt value: safeStorage encryption is not available",
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
|
|
||||||
const buffer = Buffer.from(base64Data, "base64")
|
|
||||||
return safeStorage.decryptString(buffer)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to decrypt value:", error)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function encryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const encrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (encrypted[field]) {
|
|
||||||
encrypted[field] = encryptValue(encrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function decryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const decrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (decrypted[field]) {
|
|
||||||
decrypted[field] = decryptValue(decrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration preset interface
|
|
||||||
*/
|
|
||||||
export interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration file structure
|
|
||||||
*/
|
|
||||||
interface ConfigPresetsFile {
|
|
||||||
version: 1
|
|
||||||
currentPresetId: string | null
|
|
||||||
presets: ConfigPreset[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the config file
|
|
||||||
*/
|
|
||||||
function getConfigFilePath(): string {
|
|
||||||
const userDataPath = app.getPath("userData")
|
|
||||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load presets from the config file
|
|
||||||
* Decrypts sensitive fields automatically
|
|
||||||
*/
|
|
||||||
export function loadPresets(): ConfigPresetsFile {
|
|
||||||
const configPath = getConfigFilePath()
|
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(configPath, "utf-8")
|
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
|
||||||
data.presets = data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: decryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load config presets:", error)
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
currentPresetId: null,
|
|
||||||
presets: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save presets to the config file
|
|
||||||
* Encrypts sensitive fields automatically
|
|
||||||
*/
|
|
||||||
export function savePresets(data: ConfigPresetsFile): void {
|
|
||||||
const configPath = getConfigFilePath()
|
|
||||||
const userDataPath = app.getPath("userData")
|
|
||||||
|
|
||||||
// Ensure the directory exists
|
|
||||||
if (!existsSync(userDataPath)) {
|
|
||||||
mkdirSync(userDataPath, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt sensitive fields before saving
|
|
||||||
const dataToSave: ConfigPresetsFile = {
|
|
||||||
...data,
|
|
||||||
presets: data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: encryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save config presets:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all presets
|
|
||||||
*/
|
|
||||||
export function getAllPresets(): ConfigPreset[] {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.presets
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset ID
|
|
||||||
*/
|
|
||||||
export function getCurrentPresetId(): string | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.currentPresetId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset
|
|
||||||
*/
|
|
||||||
export function getCurrentPreset(): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
if (!data.currentPresetId) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return data.presets.find((p) => p.id === data.currentPresetId) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new preset
|
|
||||||
*/
|
|
||||||
export function createPreset(
|
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
|
|
||||||
): ConfigPreset {
|
|
||||||
const data = loadPresets()
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const newPreset: ConfigPreset = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: preset.name,
|
|
||||||
config: preset.config,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.push(newPreset)
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return newPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing preset
|
|
||||||
*/
|
|
||||||
export function updatePreset(
|
|
||||||
id: string,
|
|
||||||
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
|
|
||||||
): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPreset: ConfigPreset = {
|
|
||||||
...data.presets[index],
|
|
||||||
...updates,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets[index] = updatedPreset
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return updatedPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a preset
|
|
||||||
*/
|
|
||||||
export function deletePreset(id: string): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.splice(index, 1)
|
|
||||||
|
|
||||||
// Clear current preset if it was deleted
|
|
||||||
if (data.currentPresetId === id) {
|
|
||||||
data.currentPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current preset
|
|
||||||
*/
|
|
||||||
export function setCurrentPreset(id: string | null): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
|
|
||||||
if (id !== null) {
|
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
|
||||||
if (!preset) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
|
|
||||||
*/
|
|
||||||
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|
||||||
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
|
||||||
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
|
|
||||||
google: {
|
|
||||||
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
||||||
baseUrl: "GOOGLE_BASE_URL",
|
|
||||||
},
|
|
||||||
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
|
|
||||||
openrouter: {
|
|
||||||
apiKey: "OPENROUTER_API_KEY",
|
|
||||||
baseUrl: "OPENROUTER_BASE_URL",
|
|
||||||
},
|
|
||||||
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
|
|
||||||
siliconflow: {
|
|
||||||
apiKey: "SILICONFLOW_API_KEY",
|
|
||||||
baseUrl: "SILICONFLOW_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: { apiKey: "", baseUrl: "" },
|
|
||||||
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply preset environment variables to the current process
|
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
|
||||||
|
|
||||||
if (!preset) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedEnv: Record<string, string> = {}
|
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
|
||||||
if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
process.env[providerApiKey] = value
|
|
||||||
appliedEnv[providerApiKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Map generic AI_BASE_URL to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_BASE_URL" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
||||||
if (providerBaseUrl) {
|
|
||||||
process.env[providerBaseUrl] = value
|
|
||||||
appliedEnv[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
process.env[key] = value
|
|
||||||
appliedEnv[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set as current preset
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return appliedEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get environment variables from current preset
|
|
||||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
|
||||||
*/
|
|
||||||
export function getCurrentPresetEnv(): Record<string, string> {
|
|
||||||
const preset = getCurrentPreset()
|
|
||||||
if (!preset) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {}
|
|
||||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(preset.config)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
// Map generic AI_API_KEY to provider-specific key
|
|
||||||
if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
env[providerApiKey] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Map generic AI_BASE_URL to provider-specific key
|
|
||||||
else if (
|
|
||||||
key === "AI_BASE_URL" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
|
||||||
if (providerBaseUrl) {
|
|
||||||
env[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
env[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { getCurrentPresetEnv } from "./config-manager"
|
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
|
||||||
import { createWindow, getMainWindow } from "./window-manager"
|
import { createWindow, getMainWindow } from "./window-manager"
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
@@ -24,19 +22,12 @@ if (!gotTheLock) {
|
|||||||
// Load environment variables from .env files
|
// Load environment variables from .env files
|
||||||
loadEnvFile()
|
loadEnvFile()
|
||||||
|
|
||||||
// Apply saved preset environment variables (overrides .env)
|
|
||||||
const presetEnv = getCurrentPresetEnv()
|
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
|
||||||
process.env[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
let serverUrl: string | null = null
|
let serverUrl: string | null = null
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
|
||||||
|
|
||||||
// Build application menu
|
// Build application menu
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|||||||
@@ -1,43 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||||
import {
|
|
||||||
applyPresetToEnv,
|
|
||||||
type ConfigPreset,
|
|
||||||
createPreset,
|
|
||||||
deletePreset,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPreset,
|
|
||||||
getCurrentPresetId,
|
|
||||||
setCurrentPreset,
|
|
||||||
updatePreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { restartNextServer } from "./next-server"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allowed configuration keys for presets
|
|
||||||
* This whitelist prevents arbitrary environment variable injection
|
|
||||||
*/
|
|
||||||
const ALLOWED_CONFIG_KEYS = new Set([
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
"AI_API_KEY",
|
|
||||||
"AI_BASE_URL",
|
|
||||||
"TEMPERATURE",
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize preset config to only include allowed keys
|
|
||||||
*/
|
|
||||||
function sanitizePresetConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const sanitized: Record<string, string | undefined> = {}
|
|
||||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
|
||||||
if (key in config && typeof config[key] === "string") {
|
|
||||||
sanitized[key] = config[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
@@ -123,90 +84,4 @@ export function registerIpcHandlers(): void {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-all", () => {
|
|
||||||
return getAllPresets()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current", () => {
|
|
||||||
return getCurrentPreset()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("config-presets:get-current-id", () => {
|
|
||||||
return getCurrentPresetId()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"config-presets:save",
|
|
||||||
(
|
|
||||||
_event,
|
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
|
||||||
id?: string
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
// Validate preset name
|
|
||||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
|
||||||
throw new Error("Invalid preset name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize config to only allow whitelisted keys
|
|
||||||
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
|
|
||||||
|
|
||||||
if (preset.id) {
|
|
||||||
// Update existing preset
|
|
||||||
return updatePreset(preset.id, {
|
|
||||||
name: preset.name.trim(),
|
|
||||||
config: sanitizedConfig,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Create new preset
|
|
||||||
return createPreset({
|
|
||||||
name: preset.name.trim(),
|
|
||||||
config: sanitizedConfig,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
|
||||||
return deletePreset(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
|
|
||||||
const env = applyPresetToEnv(id)
|
|
||||||
if (!env) {
|
|
||||||
return { success: false, error: "Preset not found" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development"
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
// In development mode, the config file change will trigger
|
|
||||||
// the file watcher in electron-dev.mjs to restart Next.js
|
|
||||||
// We just need to save the preset (already done in applyPresetToEnv)
|
|
||||||
return { success: true, env, devMode: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production mode: restart the Next.js server to apply new environment variables
|
|
||||||
try {
|
|
||||||
await restartNextServer()
|
|
||||||
return { success: true, env }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to restart server",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"config-presets:set-current",
|
|
||||||
(_event, id: string | null) => {
|
|
||||||
return setCurrentPreset(id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import path from "node:path"
|
|
||||||
import { app, BrowserWindow, ipcMain } from "electron"
|
|
||||||
|
|
||||||
let settingsWindow: BrowserWindow | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and show the settings window
|
|
||||||
*/
|
|
||||||
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
|
||||||
// If settings window already exists, focus it
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine path to settings preload script
|
|
||||||
// In compiled output: dist-electron/preload/settings.js
|
|
||||||
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
|
||||||
|
|
||||||
// Determine path to settings HTML
|
|
||||||
// In packaged app: app.asar/dist-electron/settings/index.html
|
|
||||||
// In development: electron/settings/index.html
|
|
||||||
const settingsHtmlPath = app.isPackaged
|
|
||||||
? path.join(__dirname, "..", "settings", "index.html")
|
|
||||||
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
|
||||||
|
|
||||||
settingsWindow = new BrowserWindow({
|
|
||||||
width: 600,
|
|
||||||
height: 700,
|
|
||||||
minWidth: 500,
|
|
||||||
minHeight: 500,
|
|
||||||
parent: parentWindow,
|
|
||||||
modal: false,
|
|
||||||
show: false,
|
|
||||||
title: "Settings - Next AI Draw.io",
|
|
||||||
webPreferences: {
|
|
||||||
preload: preloadPath,
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
settingsWindow.loadFile(settingsHtmlPath)
|
|
||||||
|
|
||||||
settingsWindow.once("ready-to-show", () => {
|
|
||||||
settingsWindow?.show()
|
|
||||||
})
|
|
||||||
|
|
||||||
settingsWindow.on("closed", () => {
|
|
||||||
settingsWindow = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the settings window if it exists
|
|
||||||
*/
|
|
||||||
export function closeSettingsWindow(): void {
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.close()
|
|
||||||
settingsWindow = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if settings window is open
|
|
||||||
*/
|
|
||||||
export function isSettingsWindowOpen(): boolean {
|
|
||||||
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register settings window IPC handlers
|
|
||||||
*/
|
|
||||||
export function registerSettingsWindowHandlers(): void {
|
|
||||||
ipcMain.on("settings:close", () => {
|
|
||||||
closeSettingsWindow()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Preload script for settings window
|
|
||||||
* Exposes APIs for managing configuration presets
|
|
||||||
*/
|
|
||||||
import { contextBridge, ipcRenderer } from "electron"
|
|
||||||
|
|
||||||
// Expose settings API to the renderer process
|
|
||||||
contextBridge.exposeInMainWorld("settingsAPI", {
|
|
||||||
// Get all presets
|
|
||||||
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
|
|
||||||
|
|
||||||
// Get current preset ID
|
|
||||||
getCurrentPresetId: () =>
|
|
||||||
ipcRenderer.invoke("config-presets:get-current-id"),
|
|
||||||
|
|
||||||
// Get current preset
|
|
||||||
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
|
|
||||||
|
|
||||||
// Save (create or update) a preset
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => ipcRenderer.invoke("config-presets:save", preset),
|
|
||||||
|
|
||||||
// Delete a preset
|
|
||||||
deletePreset: (id: string) =>
|
|
||||||
ipcRenderer.invoke("config-presets:delete", id),
|
|
||||||
|
|
||||||
// Apply a preset (sets environment variables and restarts server)
|
|
||||||
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
|
|
||||||
|
|
||||||
// Close settings window
|
|
||||||
close: () => ipcRenderer.send("settings:close"),
|
|
||||||
})
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
|
|
||||||
<title>Settings - Next AI Draw.io</title>
|
|
||||||
<link rel="stylesheet" href="./settings.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Configuration Presets</h1>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Presets</h2>
|
|
||||||
<div id="preset-list" class="preset-list">
|
|
||||||
<!-- Presets will be loaded here -->
|
|
||||||
</div>
|
|
||||||
<button id="add-preset-btn" class="btn btn-primary">
|
|
||||||
+ Add New Preset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Preset Modal -->
|
|
||||||
<div id="preset-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="modal-title">Add Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="preset-form">
|
|
||||||
<input type="hidden" id="preset-id">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="preset-name">Preset Name *</label>
|
|
||||||
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-provider">AI Provider</label>
|
|
||||||
<select id="ai-provider">
|
|
||||||
<option value="">-- Select Provider --</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
|
||||||
<option value="google">Google AI (Gemini)</option>
|
|
||||||
<option value="azure">Azure OpenAI</option>
|
|
||||||
<option value="bedrock">AWS Bedrock</option>
|
|
||||||
<option value="openrouter">OpenRouter</option>
|
|
||||||
<option value="deepseek">DeepSeek</option>
|
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
|
||||||
<option value="ollama">Ollama (Local)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-model">Model ID</label>
|
|
||||||
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
|
|
||||||
<div class="hint">The model identifier to use with the selected provider</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-api-key">API Key</label>
|
|
||||||
<input type="password" id="ai-api-key" placeholder="Your API key">
|
|
||||||
<div class="hint">This will be stored locally on your device</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-base-url">Base URL (Optional)</label>
|
|
||||||
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
|
|
||||||
<div class="hint">Custom API endpoint URL</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="temperature">Temperature (Optional)</label>
|
|
||||||
<input type="text" id="temperature" placeholder="0.7">
|
|
||||||
<div class="hint">Controls randomness (0.0 - 2.0)</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div id="delete-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Delete Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
|
|
||||||
<p class="delete-warning">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast notification -->
|
|
||||||
<div id="toast" class="toast"></div>
|
|
||||||
|
|
||||||
<script src="./settings.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-primary: #ffffff;
|
|
||||||
--bg-secondary: #f5f5f5;
|
|
||||||
--bg-hover: #e8e8e8;
|
|
||||||
--text-primary: #1a1a1a;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--border-color: #e0e0e0;
|
|
||||||
--accent-color: #0066cc;
|
|
||||||
--accent-hover: #0052a3;
|
|
||||||
--danger-color: #dc3545;
|
|
||||||
--success-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg-primary: #1a1a1a;
|
|
||||||
--bg-secondary: #2d2d2d;
|
|
||||||
--bg-hover: #3d3d3d;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
--border-color: #404040;
|
|
||||||
--accent-color: #4da6ff;
|
|
||||||
--accent-hover: #66b3ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
|
||||||
sans-serif;
|
|
||||||
background-color: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card.active {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-badge {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 100;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.show {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 480px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group .hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: inline-block;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: var(--bg-primary);
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 200;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.success {
|
|
||||||
background: var(--success-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.error {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline style replacements */
|
|
||||||
.delete-warning {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
// Settings page JavaScript
|
|
||||||
// This file handles the UI interactions for the settings window
|
|
||||||
|
|
||||||
let presets = []
|
|
||||||
let currentPresetId = null
|
|
||||||
let editingPresetId = null
|
|
||||||
let deletingPresetId = null
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const presetList = document.getElementById("preset-list")
|
|
||||||
const addPresetBtn = document.getElementById("add-preset-btn")
|
|
||||||
const presetModal = document.getElementById("preset-modal")
|
|
||||||
const deleteModal = document.getElementById("delete-modal")
|
|
||||||
const presetForm = document.getElementById("preset-form")
|
|
||||||
const modalTitle = document.getElementById("modal-title")
|
|
||||||
const toast = document.getElementById("toast")
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
const presetIdField = document.getElementById("preset-id")
|
|
||||||
const presetNameField = document.getElementById("preset-name")
|
|
||||||
const aiProviderField = document.getElementById("ai-provider")
|
|
||||||
const aiModelField = document.getElementById("ai-model")
|
|
||||||
const aiApiKeyField = document.getElementById("ai-api-key")
|
|
||||||
const aiBaseUrlField = document.getElementById("ai-base-url")
|
|
||||||
const temperatureField = document.getElementById("temperature")
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
const cancelBtn = document.getElementById("cancel-btn")
|
|
||||||
const saveBtn = document.getElementById("save-btn")
|
|
||||||
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
|
|
||||||
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
await loadPresets()
|
|
||||||
setupEventListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load presets from main process
|
|
||||||
async function loadPresets() {
|
|
||||||
try {
|
|
||||||
presets = await window.settingsAPI.getPresets()
|
|
||||||
currentPresetId = await window.settingsAPI.getCurrentPresetId()
|
|
||||||
renderPresets()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load presets:", error)
|
|
||||||
showToast("Failed to load presets", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render presets list
|
|
||||||
function renderPresets() {
|
|
||||||
if (presets.length === 0) {
|
|
||||||
presetList.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No presets configured yet.</p>
|
|
||||||
<p>Add a preset to quickly switch between different AI configurations.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
presetList.innerHTML = presets
|
|
||||||
.map((preset) => {
|
|
||||||
const isActive = preset.id === currentPresetId
|
|
||||||
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
|
|
||||||
<div class="preset-header">
|
|
||||||
<span class="preset-name">${escapeHtml(preset.name)}</span>
|
|
||||||
${isActive ? '<span class="preset-badge">Active</span>' : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-info">
|
|
||||||
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
|
|
||||||
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-actions">
|
|
||||||
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
|
|
||||||
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
|
|
||||||
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
|
|
||||||
// Add event listeners to buttons
|
|
||||||
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
applyPreset(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openEditModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openDeleteModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
function setupEventListeners() {
|
|
||||||
addPresetBtn.addEventListener("click", () => openAddModal())
|
|
||||||
cancelBtn.addEventListener("click", () => closeModal())
|
|
||||||
saveBtn.addEventListener("click", () => savePreset())
|
|
||||||
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
|
|
||||||
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
|
|
||||||
|
|
||||||
// Close modal on overlay click
|
|
||||||
presetModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === presetModal) closeModal()
|
|
||||||
})
|
|
||||||
deleteModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === deleteModal) closeDeleteModal()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle Enter key in form
|
|
||||||
presetForm.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
savePreset()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open add modal
|
|
||||||
function openAddModal() {
|
|
||||||
editingPresetId = null
|
|
||||||
modalTitle.textContent = "Add Preset"
|
|
||||||
presetForm.reset()
|
|
||||||
presetIdField.value = ""
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
function openEditModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
editingPresetId = id
|
|
||||||
modalTitle.textContent = "Edit Preset"
|
|
||||||
|
|
||||||
presetIdField.value = preset.id
|
|
||||||
presetNameField.value = preset.name
|
|
||||||
aiProviderField.value = preset.config.AI_PROVIDER || ""
|
|
||||||
aiModelField.value = preset.config.AI_MODEL || ""
|
|
||||||
aiApiKeyField.value = preset.config.AI_API_KEY || ""
|
|
||||||
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
|
|
||||||
temperatureField.value = preset.config.TEMPERATURE || ""
|
|
||||||
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
presetModal.classList.remove("show")
|
|
||||||
editingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open delete modal
|
|
||||||
function openDeleteModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
deletingPresetId = id
|
|
||||||
document.getElementById("delete-preset-name").textContent = preset.name
|
|
||||||
deleteModal.classList.add("show")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close delete modal
|
|
||||||
function closeDeleteModal() {
|
|
||||||
deleteModal.classList.remove("show")
|
|
||||||
deletingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save preset
|
|
||||||
async function savePreset() {
|
|
||||||
const name = presetNameField.value.trim()
|
|
||||||
if (!name) {
|
|
||||||
showToast("Please enter a preset name", "error")
|
|
||||||
presetNameField.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = {
|
|
||||||
id: editingPresetId || undefined,
|
|
||||||
name: name,
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER: aiProviderField.value || undefined,
|
|
||||||
AI_MODEL: aiModelField.value.trim() || undefined,
|
|
||||||
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
|
|
||||||
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
|
|
||||||
TEMPERATURE: temperatureField.value.trim() || undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove undefined values
|
|
||||||
Object.keys(preset.config).forEach((key) => {
|
|
||||||
if (preset.config[key] === undefined) {
|
|
||||||
delete preset.config[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
saveBtn.disabled = true
|
|
||||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.savePreset(preset)
|
|
||||||
await loadPresets()
|
|
||||||
closeModal()
|
|
||||||
showToast(
|
|
||||||
editingPresetId ? "Preset updated" : "Preset created",
|
|
||||||
"success",
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save preset:", error)
|
|
||||||
showToast("Failed to save preset", "error")
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false
|
|
||||||
saveBtn.textContent = "Save"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm delete
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deletingPresetId) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
deleteConfirmBtn.disabled = true
|
|
||||||
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.deletePreset(deletingPresetId)
|
|
||||||
await loadPresets()
|
|
||||||
closeDeleteModal()
|
|
||||||
showToast("Preset deleted", "success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete preset:", error)
|
|
||||||
showToast("Failed to delete preset", "error")
|
|
||||||
} finally {
|
|
||||||
deleteConfirmBtn.disabled = false
|
|
||||||
deleteConfirmBtn.textContent = "Delete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply preset
|
|
||||||
async function applyPreset(id) {
|
|
||||||
try {
|
|
||||||
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true
|
|
||||||
btn.innerHTML = '<span class="loading"></span>'
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await window.settingsAPI.applyPreset(id)
|
|
||||||
if (result.success) {
|
|
||||||
currentPresetId = id
|
|
||||||
renderPresets()
|
|
||||||
showToast("Preset applied, server restarting...", "success")
|
|
||||||
} else {
|
|
||||||
showToast(result.error || "Failed to apply preset", "error")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to apply preset:", error)
|
|
||||||
showToast("Failed to apply preset", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider display label
|
|
||||||
function getProviderLabel(provider) {
|
|
||||||
const labels = {
|
|
||||||
openai: "OpenAI",
|
|
||||||
anthropic: "Anthropic",
|
|
||||||
google: "Google AI",
|
|
||||||
azure: "Azure OpenAI",
|
|
||||||
bedrock: "AWS Bedrock",
|
|
||||||
openrouter: "OpenRouter",
|
|
||||||
deepseek: "DeepSeek",
|
|
||||||
siliconflow: "SiliconFlow",
|
|
||||||
ollama: "Ollama",
|
|
||||||
}
|
|
||||||
return labels[provider] || provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show toast notification
|
|
||||||
function showToast(message, type = "") {
|
|
||||||
toast.textContent = message
|
|
||||||
toast.className = "toast show" + (type ? ` ${type}` : "")
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove("show")
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement("div")
|
|
||||||
div.textContent = text
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
383
hooks/use-diagram-tool-handlers.ts
Normal file
383
hooks/use-diagram-tool-handlers.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import type { MutableRefObject } from "react"
|
||||||
|
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
|
interface ToolCall {
|
||||||
|
toolCallId: string
|
||||||
|
toolName: string
|
||||||
|
input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddToolOutputSuccess = {
|
||||||
|
tool: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: "output-available"
|
||||||
|
output: string
|
||||||
|
errorText?: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddToolOutputError = {
|
||||||
|
tool: string
|
||||||
|
toolCallId: string
|
||||||
|
state: "output-error"
|
||||||
|
output?: undefined
|
||||||
|
errorText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
|
||||||
|
|
||||||
|
type AddToolOutputFn = (params: AddToolOutputParams) => void
|
||||||
|
|
||||||
|
interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDiagramToolHandlersParams {
|
||||||
|
partialXmlRef: MutableRefObject<string>
|
||||||
|
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||||
|
chartXMLRef: MutableRefObject<string>
|
||||||
|
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||||
|
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||||
|
onExport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||||
|
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||||
|
*
|
||||||
|
* Note: addToolOutput is passed at call time (not hook init) because
|
||||||
|
* it comes from useChat which creates a circular dependency.
|
||||||
|
*/
|
||||||
|
export function useDiagramToolHandlers({
|
||||||
|
partialXmlRef,
|
||||||
|
editDiagramOriginalXmlRef,
|
||||||
|
chartXMLRef,
|
||||||
|
onDisplayChart,
|
||||||
|
onFetchChart,
|
||||||
|
onExport,
|
||||||
|
}: UseDiagramToolHandlersParams) {
|
||||||
|
const handleToolCall = async (
|
||||||
|
{ toolCall }: { toolCall: ToolCall },
|
||||||
|
addToolOutput: AddToolOutputFn,
|
||||||
|
) => {
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCall.toolName === "display_diagram") {
|
||||||
|
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||||
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
|
await handleEditDiagram(toolCall, addToolOutput)
|
||||||
|
} else if (toolCall.toolName === "append_diagram") {
|
||||||
|
handleAppendDiagram(toolCall, addToolOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisplayDiagram = async (
|
||||||
|
toolCall: ToolCall,
|
||||||
|
addToolOutput: AddToolOutputFn,
|
||||||
|
) => {
|
||||||
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
|
// DEBUG: Log raw input to diagnose false truncation detection
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] XML ending (last 100 chars):",
|
||||||
|
xml.slice(-100),
|
||||||
|
)
|
||||||
|
console.log("[display_diagram] XML length:", xml.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||||
|
const isTruncated = !isMxCellXmlComplete(xml)
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTruncated) {
|
||||||
|
// Store the partial XML for continuation via append_diagram
|
||||||
|
partialXmlRef.current = xml
|
||||||
|
|
||||||
|
// Tell LLM to use append_diagram to continue
|
||||||
|
const partialEnding = partialXmlRef.current.slice(-500)
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||||
|
|
||||||
|
Your output ended with:
|
||||||
|
\`\`\`
|
||||||
|
${partialEnding}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
NEXT STEP: Call append_diagram with the continuation XML.
|
||||||
|
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||||
|
- Start from EXACTLY where you stopped
|
||||||
|
- Complete all remaining mxCell elements`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete XML received - use it directly
|
||||||
|
// (continuation is now handled via append_diagram tool)
|
||||||
|
const finalXml = xml
|
||||||
|
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||||
|
|
||||||
|
// Wrap raw XML with full mxfile structure for draw.io
|
||||||
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
|
|
||||||
|
// loadDiagram validates and returns error if invalid
|
||||||
|
const validationError = onDisplayChart(fullXml)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
console.warn("[display_diagram] Validation error:", validationError)
|
||||||
|
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Adding tool output with state: output-error",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `${validationError}
|
||||||
|
|
||||||
|
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||||
|
|
||||||
|
Your failed XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${finalXml}
|
||||||
|
\`\`\``,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Success - diagram will be rendered by chat-message-display
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Success! Adding tool output with state: output-available",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
addToolOutput({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Successfully displayed the diagram.",
|
||||||
|
})
|
||||||
|
if (DEBUG) {
|
||||||
|
console.log(
|
||||||
|
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditDiagram = async (
|
||||||
|
toolCall: ToolCall,
|
||||||
|
addToolOutput: AddToolOutputFn,
|
||||||
|
) => {
|
||||||
|
const { operations } = toolCall.input as {
|
||||||
|
operations: DiagramOperation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentXml = ""
|
||||||
|
try {
|
||||||
|
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||||
|
// This ensures we apply operations to the same base XML that streaming used
|
||||||
|
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||||
|
toolCall.toolCallId,
|
||||||
|
)
|
||||||
|
if (originalXml) {
|
||||||
|
currentXml = originalXml
|
||||||
|
} else {
|
||||||
|
// Fallback: use chartXML from ref if streaming didn't capture original
|
||||||
|
const cachedXML = chartXMLRef.current
|
||||||
|
if (cachedXML) {
|
||||||
|
currentXml = cachedXML
|
||||||
|
} else {
|
||||||
|
// Last resort: export from iframe
|
||||||
|
currentXml = await onFetchChart(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||||
|
const { result: editedXml, errors } = applyDiagramOperations(
|
||||||
|
currentXml,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check for operation errors
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const errorMessages = errors
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Some operations failed:\n${errorMessages}
|
||||||
|
|
||||||
|
Current diagram XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${currentXml}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please check the cell IDs and retry.`,
|
||||||
|
})
|
||||||
|
// Clean up the shared original XML ref
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDiagram validates and returns error if invalid
|
||||||
|
const validationError = onDisplayChart(editedXml)
|
||||||
|
if (validationError) {
|
||||||
|
console.warn(
|
||||||
|
"[edit_diagram] Validation error:",
|
||||||
|
validationError,
|
||||||
|
)
|
||||||
|
addToolOutput({
|
||||||
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Edit produced invalid XML: ${validationError}
|
||||||
|
|
||||||
|
Current diagram XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${currentXml}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please fix the operations to avoid structural issues.`,
|
||||||
|
})
|
||||||
|
// Clean up the shared original XML ref
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onExport()
|
||||||
|
addToolOutput({
|
||||||
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||||
|
})
|
||||||
|
// Clean up the shared original XML ref
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[edit_diagram] Failed:", error)
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
addToolOutput({
|
||||||
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Edit failed: ${errorMessage}
|
||||||
|
|
||||||
|
Current diagram XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${currentXml || "No XML available"}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||||
|
})
|
||||||
|
// Clean up the shared original XML ref even on error
|
||||||
|
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAppendDiagram = (
|
||||||
|
toolCall: ToolCall,
|
||||||
|
addToolOutput: AddToolOutputFn,
|
||||||
|
) => {
|
||||||
|
const { xml } = toolCall.input as { xml: string }
|
||||||
|
|
||||||
|
// Detect if LLM incorrectly started fresh instead of continuing
|
||||||
|
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||||
|
const trimmed = xml.trim()
|
||||||
|
const isFreshStart =
|
||||||
|
trimmed.startsWith("<mxGraphModel") ||
|
||||||
|
trimmed.startsWith("<root") ||
|
||||||
|
trimmed.startsWith("<mxfile") ||
|
||||||
|
trimmed.startsWith('<mxCell id="0"') ||
|
||||||
|
trimmed.startsWith('<mxCell id="1"')
|
||||||
|
|
||||||
|
if (isFreshStart) {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||||
|
|
||||||
|
Continue from EXACTLY where the partial ended:
|
||||||
|
\`\`\`
|
||||||
|
${partialXmlRef.current.slice(-500)}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Start your continuation with the NEXT character after where it stopped.`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append to accumulated XML
|
||||||
|
partialXmlRef.current += xml
|
||||||
|
|
||||||
|
// Check if XML is now complete (last mxCell is complete)
|
||||||
|
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
// Wrap and display the complete diagram
|
||||||
|
const finalXml = partialXmlRef.current
|
||||||
|
partialXmlRef.current = "" // Reset
|
||||||
|
|
||||||
|
const fullXml = wrapWithMxFile(finalXml)
|
||||||
|
const validationError = onDisplayChart(fullXml)
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `Validation error after assembly: ${validationError}
|
||||||
|
|
||||||
|
Assembled XML:
|
||||||
|
\`\`\`xml
|
||||||
|
${finalXml.substring(0, 2000)}...
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please use display_diagram with corrected XML.`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Diagram assembly complete and displayed successfully.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Still incomplete - signal to continue
|
||||||
|
addToolOutput({
|
||||||
|
tool: "append_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||||
|
|
||||||
|
Current ending:
|
||||||
|
\`\`\`
|
||||||
|
${partialXmlRef.current.slice(-500)}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Continue from EXACTLY where you stopped.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleToolCall }
|
||||||
|
}
|
||||||
@@ -99,9 +99,9 @@ When using edit_diagram tool:
|
|||||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||||
- For delete: only cell_id is needed
|
- For delete: only cell_id is needed
|
||||||
- Find the cell_id from "Current diagram XML" in system context
|
- Find the cell_id from "Current diagram XML" in system context
|
||||||
- Example update: {"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
- Example delete: {"operations": [{"type": "delete", "cell_id": "5"}]}
|
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
- Example add: {"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||||
|
|
||||||
@@ -282,9 +282,9 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
|||||||
\`\`\`json
|
\`\`\`json
|
||||||
{
|
{
|
||||||
"operations": [
|
"operations": [
|
||||||
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||||
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||||
{"type": "delete", "cell_id": "5"}
|
{"operation": "delete", "cell_id": "5"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
@@ -293,17 +293,17 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
|||||||
|
|
||||||
Change label:
|
Change label:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"type": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Add new shape:
|
Add new shape:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"type": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Delete cell:
|
Delete cell:
|
||||||
\`\`\`json
|
\`\`\`json
|
||||||
{"operations": [{"type": "delete", "cell_id": "5"}]}
|
{"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
**Error Recovery:**
|
**Error Recovery:**
|
||||||
|
|||||||
12
lib/user-id.ts
Normal file
12
lib/user-id.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generate a userId from request for tracking purposes.
|
||||||
|
* Uses base64url encoding of IP for URL-safe identifier.
|
||||||
|
* Note: base64 is reversible - this is NOT privacy protection.
|
||||||
|
*/
|
||||||
|
export function getUserIdFromRequest(req: Request): string {
|
||||||
|
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||||
|
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||||
|
return rawIp === "anonymous"
|
||||||
|
? rawIp
|
||||||
|
: `user-${Buffer.from(rawIp).toString("base64url")}`
|
||||||
|
}
|
||||||
@@ -455,7 +455,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
type: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Process each operation
|
// Process each operation
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.type === "update") {
|
if (op.operation === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -580,7 +580,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Update the map with the new element
|
// Update the map with the new element
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "add") {
|
} else if (op.operation === "add") {
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -632,7 +632,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Add to map
|
// Add to map
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "delete") {
|
} else if (op.operation === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"electron:dev": "node scripts/electron-dev.mjs",
|
"electron:dev": "node scripts/electron-dev.mjs",
|
||||||
"electron:build": "npm run build && npm run electron:compile",
|
"electron:build": "npm run build && npm run electron:compile",
|
||||||
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts electron/preload/settings.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external && npx shx cp -r electron/settings dist-electron/",
|
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external",
|
||||||
"electron:start": "npx cross-env NODE_ENV=development npx electron .",
|
"electron:start": "npx cross-env NODE_ENV=development npx electron .",
|
||||||
"electron:prepare": "node scripts/prepare-electron-build.mjs",
|
"electron:prepare": "node scripts/prepare-electron-build.mjs",
|
||||||
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
|
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DiagramOperation {
|
export interface DiagramOperation {
|
||||||
type: "update" | "add" | "delete"
|
operation: "update" | "add" | "delete"
|
||||||
cell_id: string
|
cell_id: string
|
||||||
new_xml?: string
|
new_xml?: string
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Process each operation
|
// Process each operation
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.type === "update") {
|
if (op.operation === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -129,7 +129,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Update the map with the new element
|
// Update the map with the new element
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "add") {
|
} else if (op.operation === "add") {
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -181,7 +181,7 @@ export function applyDiagramOperations(
|
|||||||
|
|
||||||
// Add to map
|
// Add to map
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "delete") {
|
} else if (op.operation === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
|
|||||||
@@ -265,14 +265,22 @@ server.registerTool(
|
|||||||
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
|
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
|
||||||
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
||||||
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
|
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
|
||||||
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
|
"For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" +
|
||||||
|
"Example - Add a rectangle:\n" +
|
||||||
|
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
||||||
|
"Example - Update a cell:\n" +
|
||||||
|
'{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
|
||||||
|
"Example - Delete a cell:\n" +
|
||||||
|
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z
|
operation: z
|
||||||
.enum(["update", "add", "delete"])
|
.enum(["update", "add", "delete"])
|
||||||
.describe("Operation type"),
|
.describe(
|
||||||
|
"Operation to perform: add, update, or delete",
|
||||||
|
),
|
||||||
cell_id: z.string().describe("The id of the mxCell"),
|
cell_id: z.string().describe("The id of the mxCell"),
|
||||||
new_xml: z
|
new_xml: z
|
||||||
.string()
|
.string()
|
||||||
@@ -356,13 +364,13 @@ server.registerTool(
|
|||||||
)
|
)
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
log.info(
|
log.info(
|
||||||
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
`Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
||||||
)
|
)
|
||||||
return { ...op, new_xml: fixed }
|
return { ...op, new_xml: fixed }
|
||||||
}
|
}
|
||||||
if (!valid && error) {
|
if (!valid && error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
|
`Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Development script for running Electron with Next.js
|
* Development script for running Electron with Next.js
|
||||||
* 1. Reads preset configuration (if exists)
|
* 1. Starts Next.js dev server
|
||||||
* 2. Starts Next.js dev server with preset env vars
|
* 2. Waits for it to be ready
|
||||||
* 3. Waits for it to be ready
|
* 3. Compiles Electron TypeScript
|
||||||
* 4. Compiles Electron TypeScript
|
* 4. Launches Electron
|
||||||
* 5. Launches Electron
|
|
||||||
* 6. Watches for preset changes and restarts Next.js
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
import { existsSync, readFileSync, watch } from "node:fs"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
@@ -22,64 +18,6 @@ const rootDir = path.join(__dirname, "..")
|
|||||||
const NEXT_PORT = 6002
|
const NEXT_PORT = 6002
|
||||||
const NEXT_URL = `http://localhost:${NEXT_PORT}`
|
const NEXT_URL = `http://localhost:${NEXT_PORT}`
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user data path (same as Electron's app.getPath("userData"))
|
|
||||||
*/
|
|
||||||
function getUserDataPath() {
|
|
||||||
const appName = "next-ai-draw-io"
|
|
||||||
switch (process.platform) {
|
|
||||||
case "darwin":
|
|
||||||
return path.join(
|
|
||||||
os.homedir(),
|
|
||||||
"Library",
|
|
||||||
"Application Support",
|
|
||||||
appName,
|
|
||||||
)
|
|
||||||
case "win32":
|
|
||||||
return path.join(
|
|
||||||
process.env.APPDATA ||
|
|
||||||
path.join(os.homedir(), "AppData", "Roaming"),
|
|
||||||
appName,
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return path.join(os.homedir(), ".config", appName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load preset configuration from config file
|
|
||||||
*/
|
|
||||||
function loadPresetConfig() {
|
|
||||||
const configPath = path.join(getUserDataPath(), "config-presets.json")
|
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
console.log("📋 No preset configuration found, using .env.local")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(configPath, "utf-8")
|
|
||||||
const data = JSON.parse(content)
|
|
||||||
|
|
||||||
if (!data.currentPresetId) {
|
|
||||||
console.log("📋 No active preset, using .env.local")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = data.presets.find((p) => p.id === data.currentPresetId)
|
|
||||||
if (!preset) {
|
|
||||||
console.log("📋 Active preset not found, using .env.local")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📋 Using preset: "${preset.name}"`)
|
|
||||||
return preset.config
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load preset config:", error.message)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the Next.js server to be ready
|
* Wait for the Next.js server to be ready
|
||||||
*/
|
*/
|
||||||
@@ -129,25 +67,14 @@ function runCommand(command, args, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start Next.js dev server with preset environment
|
* Start Next.js dev server
|
||||||
*/
|
*/
|
||||||
function startNextServer(presetEnv) {
|
function startNextServer() {
|
||||||
const env = { ...process.env }
|
|
||||||
|
|
||||||
// Apply preset environment variables
|
|
||||||
if (presetEnv) {
|
|
||||||
for (const [key, value] of Object.entries(presetEnv)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
env[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProcess = spawn("npm", ["run", "dev"], {
|
const nextProcess = spawn("npm", ["run", "dev"], {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
shell: true,
|
shell: true,
|
||||||
env,
|
env: process.env,
|
||||||
})
|
})
|
||||||
|
|
||||||
nextProcess.on("error", (err) => {
|
nextProcess.on("error", (err) => {
|
||||||
@@ -163,12 +90,9 @@ function startNextServer(presetEnv) {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log("🚀 Starting Electron development environment...\n")
|
console.log("🚀 Starting Electron development environment...\n")
|
||||||
|
|
||||||
// Load preset configuration
|
// Start Next.js dev server
|
||||||
const presetEnv = loadPresetConfig()
|
|
||||||
|
|
||||||
// Start Next.js dev server with preset env
|
|
||||||
console.log("1. Starting Next.js development server...")
|
console.log("1. Starting Next.js development server...")
|
||||||
let nextProcess = startNextServer(presetEnv)
|
const nextProcess = startNextServer()
|
||||||
|
|
||||||
// Wait for Next.js to be ready
|
// Wait for Next.js to be ready
|
||||||
try {
|
try {
|
||||||
@@ -203,75 +127,14 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for preset config changes
|
|
||||||
const configPath = path.join(getUserDataPath(), "config-presets.json")
|
|
||||||
let configWatcher = null
|
|
||||||
let restartPending = false
|
|
||||||
|
|
||||||
function setupConfigWatcher() {
|
|
||||||
if (!existsSync(path.dirname(configPath))) {
|
|
||||||
// Directory doesn't exist yet, check again later
|
|
||||||
setTimeout(setupConfigWatcher, 5000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
configWatcher = watch(
|
|
||||||
configPath,
|
|
||||||
{ persistent: false },
|
|
||||||
async (eventType) => {
|
|
||||||
if (eventType === "change" && !restartPending) {
|
|
||||||
restartPending = true
|
|
||||||
console.log(
|
|
||||||
"\n🔄 Preset configuration changed, restarting Next.js server...",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Kill current Next.js process
|
|
||||||
nextProcess.kill()
|
|
||||||
|
|
||||||
// Wait a bit for process to die
|
|
||||||
await new Promise((r) => setTimeout(r, 1000))
|
|
||||||
|
|
||||||
// Reload preset and restart
|
|
||||||
const newPresetEnv = loadPresetConfig()
|
|
||||||
nextProcess = startNextServer(newPresetEnv)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForServer(NEXT_URL)
|
|
||||||
console.log(
|
|
||||||
"✅ Next.js server restarted with new configuration\n",
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"❌ Failed to restart Next.js:",
|
|
||||||
err.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
restartPending = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
console.log("👀 Watching for preset configuration changes...")
|
|
||||||
} catch (err) {
|
|
||||||
// File might not exist yet, that's ok
|
|
||||||
setTimeout(setupConfigWatcher, 5000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start watching after a delay (config file might not exist yet)
|
|
||||||
setTimeout(setupConfigWatcher, 2000)
|
|
||||||
|
|
||||||
electronProcess.on("close", (code) => {
|
electronProcess.on("close", (code) => {
|
||||||
console.log(`\nElectron exited with code ${code}`)
|
console.log(`\nElectron exited with code ${code}`)
|
||||||
if (configWatcher) configWatcher.close()
|
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(code || 0)
|
process.exit(code || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
electronProcess.on("error", (err) => {
|
electronProcess.on("error", (err) => {
|
||||||
console.error("Electron error:", err)
|
console.error("Electron error:", err)
|
||||||
if (configWatcher) configWatcher.close()
|
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
@@ -279,7 +142,6 @@ async function main() {
|
|||||||
// Handle termination signals
|
// Handle termination signals
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
console.log("\n🛑 Shutting down...")
|
console.log("\n🛑 Shutting down...")
|
||||||
if (configWatcher) configWatcher.close()
|
|
||||||
electronProcess.kill()
|
electronProcess.kill()
|
||||||
nextProcess.kill()
|
nextProcess.kill()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: "",
|
cellId: "",
|
||||||
message: `XML parse error: ${parseError.textContent}`,
|
message: `XML parse error: ${parseError.textContent}`,
|
||||||
},
|
},
|
||||||
@@ -36,7 +36,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
result: xmlContent,
|
result: xmlContent,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: "",
|
cellId: "",
|
||||||
message: "Could not find <root> element in XML",
|
message: "Could not find <root> element in XML",
|
||||||
},
|
},
|
||||||
@@ -51,11 +51,11 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const op of operations) {
|
for (const op of operations) {
|
||||||
if (op.type === "update") {
|
if (op.operation === "update") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" not found`,
|
message: `Cell with id="${op.cell_id}" not found`,
|
||||||
})
|
})
|
||||||
@@ -63,7 +63,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
}
|
}
|
||||||
if (!op.new_xml) {
|
if (!op.new_xml) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for update operation",
|
message: "new_xml is required for update operation",
|
||||||
})
|
})
|
||||||
@@ -76,7 +76,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCell = newDoc.querySelector("mxCell")
|
const newCell = newDoc.querySelector("mxCell")
|
||||||
if (!newCell) {
|
if (!newCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml must contain an mxCell element",
|
message: "new_xml must contain an mxCell element",
|
||||||
})
|
})
|
||||||
@@ -85,7 +85,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCellId = newCell.getAttribute("id")
|
const newCellId = newCell.getAttribute("id")
|
||||||
if (newCellId !== op.cell_id) {
|
if (newCellId !== op.cell_id) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "update",
|
operation: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||||
})
|
})
|
||||||
@@ -94,10 +94,10 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const importedNode = doc.importNode(newCell, true)
|
const importedNode = doc.importNode(newCell, true)
|
||||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "add") {
|
} else if (op.operation === "add") {
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
operation: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" already exists`,
|
message: `Cell with id="${op.cell_id}" already exists`,
|
||||||
})
|
})
|
||||||
@@ -105,7 +105,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
}
|
}
|
||||||
if (!op.new_xml) {
|
if (!op.new_xml) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
operation: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for add operation",
|
message: "new_xml is required for add operation",
|
||||||
})
|
})
|
||||||
@@ -118,7 +118,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCell = newDoc.querySelector("mxCell")
|
const newCell = newDoc.querySelector("mxCell")
|
||||||
if (!newCell) {
|
if (!newCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
operation: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml must contain an mxCell element",
|
message: "new_xml must contain an mxCell element",
|
||||||
})
|
})
|
||||||
@@ -127,7 +127,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const newCellId = newCell.getAttribute("id")
|
const newCellId = newCell.getAttribute("id")
|
||||||
if (newCellId !== op.cell_id) {
|
if (newCellId !== op.cell_id) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
operation: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
||||||
})
|
})
|
||||||
@@ -136,11 +136,11 @@ function applyDiagramOperations(xmlContent, operations) {
|
|||||||
const importedNode = doc.importNode(newCell, true)
|
const importedNode = doc.importNode(newCell, true)
|
||||||
root.appendChild(importedNode)
|
root.appendChild(importedNode)
|
||||||
cellMap.set(op.cell_id, importedNode)
|
cellMap.set(op.cell_id, importedNode)
|
||||||
} else if (op.type === "delete") {
|
} else if (op.operation === "delete") {
|
||||||
const existingCell = cellMap.get(op.cell_id)
|
const existingCell = cellMap.get(op.cell_id)
|
||||||
if (!existingCell) {
|
if (!existingCell) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "delete",
|
operation: "delete",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: `Cell with id="${op.cell_id}" not found`,
|
message: `Cell with id="${op.cell_id}" not found`,
|
||||||
})
|
})
|
||||||
@@ -201,7 +201,7 @@ function assert(condition, message) {
|
|||||||
test("Update operation changes cell value", () => {
|
test("Update operation changes cell value", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
@@ -224,7 +224,7 @@ test("Update operation changes cell value", () => {
|
|||||||
test("Update operation fails for non-existent cell", () => {
|
test("Update operation fails for non-existent cell", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cell_id: "999",
|
cell_id: "999",
|
||||||
new_xml: '<mxCell id="999" value="Test"/>',
|
new_xml: '<mxCell id="999" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -239,7 +239,7 @@ test("Update operation fails for non-existent cell", () => {
|
|||||||
test("Update operation fails on ID mismatch", () => {
|
test("Update operation fails on ID mismatch", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -254,7 +254,7 @@ test("Update operation fails on ID mismatch", () => {
|
|||||||
test("Add operation creates new cell", () => {
|
test("Add operation creates new cell", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "add",
|
operation: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
@@ -274,7 +274,7 @@ test("Add operation creates new cell", () => {
|
|||||||
test("Add operation fails for duplicate ID", () => {
|
test("Add operation fails for duplicate ID", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "add",
|
operation: "add",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml: '<mxCell id="2" value="Duplicate"/>',
|
new_xml: '<mxCell id="2" value="Duplicate"/>',
|
||||||
},
|
},
|
||||||
@@ -289,7 +289,7 @@ test("Add operation fails for duplicate ID", () => {
|
|||||||
test("Add operation fails on ID mismatch", () => {
|
test("Add operation fails on ID mismatch", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "add",
|
operation: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||||
},
|
},
|
||||||
@@ -303,7 +303,7 @@ test("Add operation fails on ID mismatch", () => {
|
|||||||
|
|
||||||
test("Delete operation removes cell", () => {
|
test("Delete operation removes cell", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{ type: "delete", cell_id: "3" },
|
{ operation: "delete", cell_id: "3" },
|
||||||
])
|
])
|
||||||
assert(
|
assert(
|
||||||
errors.length === 0,
|
errors.length === 0,
|
||||||
@@ -315,7 +315,7 @@ test("Delete operation removes cell", () => {
|
|||||||
|
|
||||||
test("Delete operation fails for non-existent cell", () => {
|
test("Delete operation fails for non-existent cell", () => {
|
||||||
const { errors } = applyDiagramOperations(sampleXml, [
|
const { errors } = applyDiagramOperations(sampleXml, [
|
||||||
{ type: "delete", cell_id: "999" },
|
{ operation: "delete", cell_id: "999" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
assert(
|
assert(
|
||||||
@@ -327,18 +327,18 @@ test("Delete operation fails for non-existent cell", () => {
|
|||||||
test("Multiple operations in sequence", () => {
|
test("Multiple operations in sequence", () => {
|
||||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||||
{
|
{
|
||||||
type: "update",
|
operation: "update",
|
||||||
cell_id: "2",
|
cell_id: "2",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "add",
|
operation: "add",
|
||||||
cell_id: "new1",
|
cell_id: "new1",
|
||||||
new_xml:
|
new_xml:
|
||||||
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
|
||||||
},
|
},
|
||||||
{ type: "delete", cell_id: "3" },
|
{ operation: "delete", cell_id: "3" },
|
||||||
])
|
])
|
||||||
assert(
|
assert(
|
||||||
errors.length === 0,
|
errors.length === 0,
|
||||||
@@ -354,14 +354,14 @@ test("Multiple operations in sequence", () => {
|
|||||||
|
|
||||||
test("Invalid XML returns parse error", () => {
|
test("Invalid XML returns parse error", () => {
|
||||||
const { errors } = applyDiagramOperations("<not valid xml", [
|
const { errors } = applyDiagramOperations("<not valid xml", [
|
||||||
{ type: "delete", cell_id: "1" },
|
{ operation: "delete", cell_id: "1" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Missing root element returns error", () => {
|
test("Missing root element returns error", () => {
|
||||||
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
|
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
|
||||||
{ type: "delete", cell_id: "1" },
|
{ operation: "delete", cell_id: "1" },
|
||||||
])
|
])
|
||||||
assert(errors.length === 1, "Should have one error")
|
assert(errors.length === 1, "Should have one error")
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
Reference in New Issue
Block a user