mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
Compare commits
1 Commits
remove-ele
...
chore/upgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d453b533 |
@@ -147,13 +147,14 @@ Download the native desktop app for your platform from the [Releases page](https
|
||||
| Linux | `.AppImage` or `.deb` (x64 & ARM64) |
|
||||
|
||||
**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
|
||||
- **Offline capable**: Works without internet after first launch
|
||||
- **Built-in settings**: Configure AI providers directly in the app
|
||||
|
||||
**Quick Setup:**
|
||||
1. Download and install for your platform
|
||||
2. Click the settings icon in the chat panel
|
||||
2. Open the app → **Menu → Configuration → Manage Presets**
|
||||
3. Add your AI provider credentials
|
||||
4. Start creating diagrams!
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ import path from "path"
|
||||
import { z } from "zod"
|
||||
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import {
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
@@ -26,7 +21,6 @@ import {
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
|
||||
@@ -168,8 +162,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
||||
|
||||
// Get user ID for Langfuse tracking and quota
|
||||
const userId = getUserIdFromRequest(req)
|
||||
// Get user IP for Langfuse tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||
const validSessionId =
|
||||
@@ -178,12 +173,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
: undefined
|
||||
|
||||
// Extract user input text for Langfuse trace
|
||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((m: any) => m.role === "user")
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const userInputText =
|
||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
||||
|
||||
// Update Langfuse trace with input, session, and user
|
||||
setTraceInput({
|
||||
@@ -192,33 +184,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||
const hasOwnApiKey = !!(
|
||||
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key")
|
||||
)
|
||||
|
||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages)
|
||||
if (!fileValidation.valid) {
|
||||
@@ -272,10 +237,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||
|
||||
// Extract file parts (images) from the last user message
|
||||
// Extract file parts (images) from the last message
|
||||
const fileParts =
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
@@ -538,26 +502,12 @@ ${userInputText}
|
||||
userId,
|
||||
}),
|
||||
}),
|
||||
onFinish: ({ text, totalUsage }) => {
|
||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||
setTraceOutput(text)
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// Include all 4 token types: input, output, cache read, cache write
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!hasOwnApiKey &&
|
||||
userId !== "anonymous" &&
|
||||
totalUsage
|
||||
) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0) +
|
||||
(totalUsage.cachedInputTokens || 0) +
|
||||
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
onFinish: ({ text, usage }) => {
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
})
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
@@ -609,22 +559,14 @@ Operations:
|
||||
|
||||
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\\"
|
||||
|
||||
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"}]}`,
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
|
||||
inputSchema: z.object({
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z
|
||||
type: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe(
|
||||
"Operation to perform: add, update, or delete",
|
||||
),
|
||||
.describe("Operation type"),
|
||||
cell_id: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -735,9 +677,20 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
// AI SDK 6 provides totalTokens directly
|
||||
if (!usage) {
|
||||
console.warn(
|
||||
"[messageMetadata] No usage data in finish part",
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
// Total input = non-cached + cached (these are separate counts)
|
||||
// Note: cacheWriteInputTokens is not available on finish part
|
||||
const totalInputTokens =
|
||||
(usage.inputTokens ?? 0) +
|
||||
(usage.inputTokenDetails?.cacheReadTokens ?? 0)
|
||||
return {
|
||||
totalTokens: usage?.totalTokens ?? 0,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: usage.outputTokens ?? 0,
|
||||
finishReason: (part as any).finishReason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto"
|
||||
import { z } from "zod"
|
||||
import { getLangfuseClient } from "@/lib/langfuse"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
messageId: z.string().min(1).max(200),
|
||||
@@ -28,13 +27,9 @@ export async function POST(req: Request) {
|
||||
|
||||
const { messageId, feedback, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
// Get user ID for tracking
|
||||
const userId = getUserIdFromRequest(req)
|
||||
// Get user IP for tracking
|
||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||
|
||||
try {
|
||||
// Find the most recent chat trace for this session to attach the score to
|
||||
|
||||
@@ -27,11 +27,6 @@ export async function POST(req: Request) {
|
||||
|
||||
const { filename, format, sessionId } = data
|
||||
|
||||
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||
if (!sessionId) {
|
||||
return Response.json({ success: true, logged: false })
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
|
||||
142
app/globals.css
142
app/globals.css
@@ -144,68 +144,6 @@
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED MINIMAL DESIGN SYSTEM
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Surface layers for depth */
|
||||
--surface-0: oklch(1 0 0);
|
||||
--surface-1: oklch(0.985 0.002 240);
|
||||
--surface-2: oklch(0.97 0.004 240);
|
||||
--surface-elevated: oklch(1 0 0);
|
||||
|
||||
/* Subtle borders */
|
||||
--border-subtle: oklch(0.94 0.008 260);
|
||||
--border-default: oklch(0.91 0.012 260);
|
||||
|
||||
/* Interactive states */
|
||||
--interactive-hover: oklch(0.96 0.015 260);
|
||||
--interactive-active: oklch(0.93 0.02 265);
|
||||
|
||||
/* Success state */
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-muted: oklch(0.95 0.03 145);
|
||||
|
||||
/* Animation timing */
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface-0: oklch(0.15 0.015 260);
|
||||
--surface-1: oklch(0.18 0.015 260);
|
||||
--surface-2: oklch(0.22 0.015 260);
|
||||
--surface-elevated: oklch(0.25 0.015 260);
|
||||
|
||||
--border-subtle: oklch(0.25 0.012 260);
|
||||
--border-default: oklch(0.3 0.015 260);
|
||||
|
||||
--interactive-hover: oklch(0.25 0.02 265);
|
||||
--interactive-active: oklch(0.3 0.025 270);
|
||||
|
||||
--success: oklch(0.7 0.16 145);
|
||||
--success-muted: oklch(0.25 0.04 145);
|
||||
}
|
||||
|
||||
/* Expose surface colors to Tailwind */
|
||||
@theme inline {
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-elevated: var(--surface-elevated);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
--color-interactive-hover: var(--interactive-hover);
|
||||
--color-interactive-active: var(--interactive-active);
|
||||
--color-success: var(--success);
|
||||
--color-success-muted: var(--success-muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -319,83 +257,3 @@
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED DIALOG STYLES
|
||||
============================================ */
|
||||
|
||||
/* Refined dialog shadow - multi-layer soft shadow */
|
||||
.shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
||||
0 2px 4px oklch(0 0 0 / 0.02),
|
||||
0 12px 24px oklch(0 0 0 / 0.06),
|
||||
0 24px 48px oklch(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.dark .shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 2px 4px oklch(0 0 0 / 0.2),
|
||||
0 12px 24px oklch(0 0 0 / 0.3),
|
||||
0 24px 48px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Dialog animations */
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-in {
|
||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-out {
|
||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Check pop animation for validation success */
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-dialog-in,
|
||||
.animate-dialog-out,
|
||||
.animate-check-pop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,22 +66,8 @@ export const ModelSelectorInput = ({
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||
|
||||
export const ModelSelectorList = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorListProps) => (
|
||||
<div className="relative">
|
||||
<CommandList
|
||||
className={cn(
|
||||
// Hide scrollbar on all platforms
|
||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{/* Bottom shadow indicator for scrollable content */}
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
|
||||
</div>
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
)
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
isMxCellXmlComplete,
|
||||
replaceNodes,
|
||||
validateAndFixXml,
|
||||
@@ -40,7 +39,7 @@ import ExamplePanel from "./chat-example-panel"
|
||||
import { CodeBlock } from "./code-block"
|
||||
|
||||
interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
@@ -53,12 +52,12 @@ function getCompleteOperations(
|
||||
return operations.filter(
|
||||
(op) =>
|
||||
op &&
|
||||
typeof op.operation === "string" &&
|
||||
["update", "add", "delete"].includes(op.operation) &&
|
||||
typeof op.type === "string" &&
|
||||
["update", "add", "delete"].includes(op.type) &&
|
||||
typeof op.cell_id === "string" &&
|
||||
op.cell_id.length > 0 &&
|
||||
// delete doesn't need new_xml, update/add do
|
||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||
(op.type === "delete" || typeof op.new_xml === "string"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,20 +78,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
key={`${op.type}-${op.cell_id}-${index}`}
|
||||
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">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
op.type === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
: op.type === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
{op.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
@@ -316,28 +315,12 @@ export function ChatMessageDisplay({
|
||||
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string, showToast = false) => {
|
||||
let currentXml = xml || ""
|
||||
const startTime = performance.now()
|
||||
|
||||
// During streaming (showToast=false), extract only complete mxCell elements
|
||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||
if (!showToast) {
|
||||
const completeCells = extractCompleteMxCells(currentXml)
|
||||
if (!completeCells) {
|
||||
return
|
||||
}
|
||||
currentXml = completeCells
|
||||
}
|
||||
|
||||
const currentXml = xml || ""
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
if (convertedXml !== previousXML.current) {
|
||||
// Parse and validate XML BEFORE calling replaceNodes
|
||||
const parser = new DOMParser()
|
||||
// Wrap in root element for parsing multiple mxCell elements
|
||||
const testDoc = parser.parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
||||
const parseError = testDoc.querySelector("parsererror")
|
||||
|
||||
if (parseError) {
|
||||
@@ -364,22 +347,7 @@ export function ChatMessageDisplay({
|
||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
|
||||
const xmlProcessTime = performance.now() - startTime
|
||||
|
||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||
// The quick DOM parse check above catches malformed XML
|
||||
// Full validation runs on final output (showToast=true)
|
||||
if (!showToast) {
|
||||
previousXML.current = convertedXml
|
||||
const loadStartTime = performance.now()
|
||||
onDisplayChart(replacedXML, true)
|
||||
console.log(
|
||||
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Final output: run full validation and auto-fix
|
||||
// Validate and auto-fix the XML
|
||||
const validation = validateAndFixXml(replacedXML)
|
||||
if (validation.valid) {
|
||||
previousXML.current = convertedXml
|
||||
@@ -392,19 +360,18 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
}
|
||||
// Skip validation in loadDiagram since we already validated above
|
||||
const loadStartTime = performance.now()
|
||||
onDisplayChart(xmlToLoad, true)
|
||||
console.log(
|
||||
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||
)
|
||||
} else {
|
||||
console.error(
|
||||
"[ChatMessageDisplay] XML validation failed:",
|
||||
validation.error,
|
||||
)
|
||||
toast.error(
|
||||
"Diagram validation failed. Please try regenerating.",
|
||||
)
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(
|
||||
"Diagram validation failed. Please try regenerating.",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -636,10 +603,17 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Don't cleanup debounce timeouts here!
|
||||
// The cleanup runs on every re-render (when messages changes),
|
||||
// which would cancel the timeout before it fires.
|
||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||
// Cleanup: clear any pending debounce timeout on unmount
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current)
|
||||
debounceTimeoutRef.current = null
|
||||
}
|
||||
if (editDebounceTimeoutRef.current) {
|
||||
clearTimeout(editDebounceTimeoutRef.current)
|
||||
editDebounceTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [messages, handleDisplayChart, chartXML])
|
||||
|
||||
const renderToolPart = (part: ToolPartLike) => {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -35,9 +34,8 @@ import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { formatXML } from "@/lib/utils"
|
||||
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||
|
||||
// localStorage keys for persistence
|
||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||
@@ -79,8 +77,6 @@ const TOOL_ERROR_STATE = "output-error" as const
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
const MAX_AUTO_RETRY_COUNT = 1
|
||||
|
||||
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
|
||||
|
||||
/**
|
||||
* Check if auto-resubmit should happen based on tool errors.
|
||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||
@@ -215,10 +211,11 @@ export default function ChatPanel({
|
||||
chartXMLRef.current = 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)
|
||||
const autoRetryCountRef = useRef(0)
|
||||
// Ref to track continuation retry count (for truncation handling)
|
||||
const continuationRetryCountRef = useRef(0)
|
||||
|
||||
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens
|
||||
// When partialXmlRef.current.length > 0, we're in continuation mode
|
||||
@@ -237,16 +234,6 @@ export default function ChatPanel({
|
||||
> | null>(null)
|
||||
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 {
|
||||
messages,
|
||||
sendMessage,
|
||||
@@ -259,47 +246,313 @@ export default function ChatPanel({
|
||||
transport: new DefaultChatTransport({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
}),
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
async onToolCall({ toolCall }) {
|
||||
if (DEBUG) {
|
||||
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) => {
|
||||
// Handle server-side quota limit (429 response)
|
||||
// AI SDK puts the full response body in error.message for non-OK responses
|
||||
try {
|
||||
const data = JSON.parse(error.message)
|
||||
if (data.type === "request") {
|
||||
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (data.type === "token") {
|
||||
quotaManager.showTokenLimitToast(data.used, data.limit)
|
||||
return
|
||||
}
|
||||
if (data.type === "tpm") {
|
||||
quotaManager.showTPMLimitToast(data.limit)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, fall through to string matching for backwards compatibility
|
||||
}
|
||||
|
||||
// Fallback to string matching
|
||||
if (error.message.includes("Daily request limit")) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
return
|
||||
}
|
||||
if (error.message.includes("Daily token limit")) {
|
||||
quotaManager.showTokenLimitToast()
|
||||
return
|
||||
}
|
||||
if (
|
||||
error.message.includes("Rate limit exceeded") ||
|
||||
error.message.includes("tokens per minute")
|
||||
) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return
|
||||
}
|
||||
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
@@ -376,6 +629,22 @@ export default function ChatPanel({
|
||||
|
||||
// DEBUG: Log finish reason to diagnose truncation
|
||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
||||
console.log("[onFinish] metadata:", metadata)
|
||||
|
||||
if (metadata) {
|
||||
// Use Number.isFinite to guard against NaN (typeof NaN === 'number' is true)
|
||||
const inputTokens = Number.isFinite(metadata.inputTokens)
|
||||
? (metadata.inputTokens as number)
|
||||
: 0
|
||||
const outputTokens = Number.isFinite(metadata.outputTokens)
|
||||
? (metadata.outputTokens as number)
|
||||
: 0
|
||||
const actualTokens = inputTokens + outputTokens
|
||||
if (actualTokens > 0) {
|
||||
quotaManager.incrementTokenCount(actualTokens)
|
||||
quotaManager.incrementTPMCount(actualTokens)
|
||||
}
|
||||
}
|
||||
},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
@@ -387,25 +656,15 @@ export default function ChatPanel({
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// Continuation mode: limited retries for truncation handling
|
||||
// Continuation mode: unlimited retries (truncation continuation, not real errors)
|
||||
// Server limits to 5 steps via stepCountIs(5)
|
||||
if (isInContinuationMode) {
|
||||
if (
|
||||
continuationRetryCountRef.current >=
|
||||
MAX_CONTINUATION_RETRY_COUNT
|
||||
) {
|
||||
toast.error(
|
||||
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
|
||||
)
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
continuationRetryCountRef.current++
|
||||
// Don't count against retry limit for continuation
|
||||
// Quota checks still apply below
|
||||
} else {
|
||||
// Regular error: check retry count limit
|
||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||
@@ -420,10 +679,30 @@ export default function ChatPanel({
|
||||
autoRetryCountRef.current++
|
||||
}
|
||||
|
||||
// Check quota limits before auto-retry
|
||||
const tokenLimitCheck = quotaManager.checkTokenLimit()
|
||||
if (!tokenLimitCheck.allowed) {
|
||||
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
const tpmCheck = quotaManager.checkTPMLimit()
|
||||
if (!tpmCheck.allowed) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// Update stopRef so onToolCall can access it
|
||||
stopRef.current = stop
|
||||
|
||||
// Ref to track latest messages for unload persistence
|
||||
const messagesRef = useRef(messages)
|
||||
useEffect(() => {
|
||||
@@ -633,6 +912,9 @@ export default function ChatPanel({
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
saveXmlSnapshots()
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
@@ -710,7 +992,30 @@ export default function ChatPanel({
|
||||
saveXmlSnapshots()
|
||||
}
|
||||
|
||||
// Send chat message with headers
|
||||
// Check all quota limits (daily requests, tokens, TPM)
|
||||
const checkAllQuotaLimits = (): boolean => {
|
||||
const limitCheck = quotaManager.checkDailyLimit()
|
||||
if (!limitCheck.allowed) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
return false
|
||||
}
|
||||
|
||||
const tokenLimitCheck = quotaManager.checkTokenLimit()
|
||||
if (!tokenLimitCheck.allowed) {
|
||||
quotaManager.showTokenLimitToast(tokenLimitCheck.used)
|
||||
return false
|
||||
}
|
||||
|
||||
const tpmCheck = quotaManager.checkTPMLimit()
|
||||
if (!tpmCheck.allowed) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Send chat message with headers and increment quota
|
||||
const sendChatMessage = (
|
||||
parts: any,
|
||||
xml: string,
|
||||
@@ -719,7 +1024,6 @@ export default function ChatPanel({
|
||||
) => {
|
||||
// Reset all retry/continuation state on user-initiated message
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
|
||||
const config = getSelectedAIConfig()
|
||||
@@ -760,6 +1064,7 @@ export default function ChatPanel({
|
||||
},
|
||||
},
|
||||
)
|
||||
quotaManager.incrementRequestCount()
|
||||
}
|
||||
|
||||
// Process files and append content to user text (handles PDF, text, and optionally images)
|
||||
@@ -847,8 +1152,13 @@ export default function ChatPanel({
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
}
|
||||
|
||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||
@@ -890,8 +1200,12 @@ export default function ChatPanel({
|
||||
setMessages(newMessages)
|
||||
})
|
||||
|
||||
// Check all quota limits
|
||||
if (!checkAllQuotaLimits()) return
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
}
|
||||
|
||||
// Collapsed view (desktop only)
|
||||
@@ -1041,14 +1355,6 @@ export default function ChatPanel({
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Dev XML Streaming Simulator - only in development */}
|
||||
{DEBUG && (
|
||||
<DevXmlSimulator
|
||||
setMessages={setMessages}
|
||||
onDisplayChart={onDisplayChart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<footer
|
||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="No" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="4" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
"Truncated (Error Test)": `<mxCell id="2" value="This cell is truncated" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
|
||||
"HTML Escape + Cell Truncate": `<mxCell id="2" value="<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/> (No explanation of reasoning)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/> (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/> (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/> (vs 84% human)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/> Works on 3-4 flips with CoT<br/>• Standard prompting fails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="<b>Emergent Ability of Scale</b><br/>• Small models (<10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="14" value="<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: "show your work"<br/>• Emergent capability of large models<br/>• No specialized architecture needed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="3" target="5">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="4" target="6">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="5" target="7">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="6" target="8">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;" edge="1" parent="1" source="7" target="9">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="7" target="10">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;" edge="1" parent="1" source="7" target="11">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="9" target="12">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="10" target="13">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="11" target="14">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="12" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="13" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="14" target="15">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>`,
|
||||
}
|
||||
|
||||
interface DevXmlSimulatorProps {
|
||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||
onDisplayChart: (xml: string) => void
|
||||
}
|
||||
|
||||
export function DevXmlSimulator({
|
||||
setMessages,
|
||||
onDisplayChart,
|
||||
}: DevXmlSimulatorProps) {
|
||||
const [devXml, setDevXml] = useState("")
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||
const [devChunkSize, setDevChunkSize] = useState(10)
|
||||
const devStopRef = useRef(false)
|
||||
const devXmlInitializedRef = useRef(false)
|
||||
|
||||
// Restore dev XML from localStorage on mount (after hydration)
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("dev-xml-simulator")
|
||||
if (saved) setDevXml(saved)
|
||||
devXmlInitializedRef.current = true
|
||||
}, [])
|
||||
|
||||
// Save dev XML to localStorage (only after initial load)
|
||||
useEffect(() => {
|
||||
if (devXmlInitializedRef.current) {
|
||||
localStorage.setItem("dev-xml-simulator", devXml)
|
||||
}
|
||||
}, [devXml])
|
||||
|
||||
const handleDevSimulate = async () => {
|
||||
if (!devXml.trim() || isSimulating) return
|
||||
|
||||
setIsSimulating(true)
|
||||
devStopRef.current = false
|
||||
const toolCallId = `dev-sim-${Date.now()}`
|
||||
const xml = devXml.trim()
|
||||
|
||||
// Add user message and initial assistant message with empty XML
|
||||
const userMsg = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "[Dev] Simulating XML streaming",
|
||||
},
|
||||
],
|
||||
}
|
||||
const assistantMsg = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "tool-display_diagram" as const,
|
||||
toolCallId,
|
||||
state: "input-streaming" as const,
|
||||
input: { xml: "" },
|
||||
},
|
||||
],
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
|
||||
|
||||
// Stream characters progressively
|
||||
for (let i = 0; i < xml.length; i += devChunkSize) {
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = xml.slice(0, i + devChunkSize)
|
||||
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].input = { xml: chunk }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, devIntervalMs))
|
||||
}
|
||||
|
||||
if (devStopRef.current) {
|
||||
setIsSimulating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Finalize: set state to output-available
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastMsg = updated[updated.length - 1] as any
|
||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||
lastMsg.parts[0].state = "output-available"
|
||||
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||
lastMsg.parts[0].input = { xml }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||
<details>
|
||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||
Dev: XML Streaming Simulator
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Preset:
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
setDevXml(DEV_XML_PRESETS[e.target.value])
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-xs p-1 border rounded bg-background"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a preset...
|
||||
</option>
|
||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDevXml("")}
|
||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={devXml}
|
||||
onChange={(e) => setDevXml(e.target.value)}
|
||||
placeholder="Paste mxCell XML here or select a preset..."
|
||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Interval:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
value={devIntervalMs}
|
||||
onChange={(e) =>
|
||||
setDevIntervalMs(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 h-1 accent-orange-500"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-12">
|
||||
{devIntervalMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Chars:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={devChunkSize}
|
||||
onChange={(e) =>
|
||||
setDevChunkSize(
|
||||
Math.max(1, Number(e.target.value)),
|
||||
)
|
||||
}
|
||||
className="w-14 text-xs p-1 border rounded bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDevSimulate}
|
||||
disabled={isSimulating || !devXml.trim()}
|
||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSimulating
|
||||
? "Streaming..."
|
||||
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||
</button>
|
||||
{isSimulating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
devStopRef.current = true
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,40 +103,41 @@ function ProviderLogo({
|
||||
)
|
||||
}
|
||||
|
||||
// Configuration section with title and optional action
|
||||
function ConfigSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
action,
|
||||
children,
|
||||
// Reusable validation button component
|
||||
function ValidationButton({
|
||||
status,
|
||||
onClick,
|
||||
disabled,
|
||||
dict,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
status: ValidationStatus
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
dict: ReturnType<typeof useDictionary>
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card wrapper with subtle depth
|
||||
function ConfigCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5">
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
variant={status === "success" ? "outline" : "default"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-9 px-4 min-w-[80px]",
|
||||
status === "success" &&
|
||||
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||
)}
|
||||
>
|
||||
{status === "validating" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : status === "success" ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
{dict.modelConfig.verified}
|
||||
</>
|
||||
) : (
|
||||
dict.modelConfig.test
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,6 +154,7 @@ export function ModelConfigDialog({
|
||||
const [validationStatus, setValidationStatus] =
|
||||
useState<ValidationStatus>("idle")
|
||||
const [validationError, setValidationError] = useState<string>("")
|
||||
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
|
||||
const [customModelInput, setCustomModelInput] = useState("")
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const validationResetTimeoutRef = useRef<ReturnType<
|
||||
@@ -184,6 +186,26 @@ export function ModelConfigDialog({
|
||||
(p) => p.id === selectedProviderId,
|
||||
)
|
||||
|
||||
// Track scroll position for gradient shadows
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
) as HTMLElement | null
|
||||
if (!scrollEl) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl
|
||||
setScrollState({
|
||||
top: scrollTop > 10,
|
||||
bottom: scrollTop < scrollHeight - clientHeight - 10,
|
||||
})
|
||||
}
|
||||
|
||||
handleScroll() // Initial check
|
||||
scrollEl.addEventListener("scroll", handleScroll)
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll)
|
||||
}, [selectedProvider])
|
||||
|
||||
// Cleanup validation reset timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -368,35 +390,34 @@ export function ModelConfigDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-surface-2">
|
||||
<DialogContent className="sm:max-w-3xl h-[75vh] max-h-[700px] overflow-hidden flex flex-col gap-0 p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b bg-gradient-to-r from-primary/5 via-primary/3 to-transparent">
|
||||
<DialogTitle className="flex items-center gap-2.5 text-xl font-semibold">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{dict.modelConfig?.title || "AI Model Configuration"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
<DialogDescription className="text-sm">
|
||||
{dict.modelConfig?.description ||
|
||||
"Configure multiple AI providers and models for your workspace"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Provider List (Left Sidebar) */}
|
||||
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
|
||||
<div className="px-4 py-3">
|
||||
<div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20">
|
||||
<div className="px-4 py-3 border-b">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{dict.modelConfig.providers}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<div className="space-y-1 pb-2">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{config.providers.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-3">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -404,78 +425,67 @@ export function ModelConfigDialog({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
config.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProviderId(
|
||||
provider.id,
|
||||
)
|
||||
setValidationStatus(
|
||||
provider.validated
|
||||
? "success"
|
||||
: "idle",
|
||||
)
|
||||
setShowApiKey(false)
|
||||
}}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
|
||||
"text-left text-sm transition-all duration-150",
|
||||
"hover:bg-interactive-hover",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
selectedProviderId ===
|
||||
provider.id &&
|
||||
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProviderId(
|
||||
provider.id,
|
||||
)
|
||||
setValidationStatus(
|
||||
provider.validated
|
||||
? "success"
|
||||
: "idle",
|
||||
)
|
||||
setShowApiKey(false)
|
||||
}}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
||||
"bg-surface-2 transition-colors duration-150",
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
selectedProviderId ===
|
||||
provider.id &&
|
||||
"bg-primary/10",
|
||||
"bg-background shadow-sm ring-1 ring-border",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={provider.provider}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{getProviderDisplayName(
|
||||
provider,
|
||||
)}
|
||||
</span>
|
||||
{provider.validated ? (
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted">
|
||||
<Check className="h-3 w-3 text-success" />
|
||||
</div>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground/50 transition-transform duration-150",
|
||||
selectedProviderId ===
|
||||
provider.id &&
|
||||
"translate-x-0.5",
|
||||
<span className="flex-1 truncate font-medium">
|
||||
{getProviderDisplayName(
|
||||
provider,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
</span>
|
||||
{provider.validated ? (
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-emerald-500/10">
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
</div>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground/50 transition-transform",
|
||||
selectedProviderId ===
|
||||
provider.id &&
|
||||
"translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add Provider */}
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
<div className="p-2 border-t">
|
||||
<Select
|
||||
onValueChange={(v) =>
|
||||
handleAddProvider(v as ProviderName)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover">
|
||||
<SelectTrigger className="h-9 bg-background hover:bg-accent">
|
||||
<Plus className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
@@ -504,23 +514,41 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
|
||||
{/* Provider Details (Right Panel) */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
||||
<div className="flex-1 min-w-0 overflow-hidden relative">
|
||||
{selectedProvider ? (
|
||||
<>
|
||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Top gradient shadow */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
|
||||
scrollState.top
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{/* Bottom gradient shadow */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
|
||||
scrollState.bottom
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ScrollArea className="h-full" ref={scrollRef}>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Provider Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={
|
||||
selectedProvider.provider
|
||||
}
|
||||
className="h-6 w-6"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg tracking-tight">
|
||||
<h3 className="font-semibold text-base">
|
||||
{
|
||||
PROVIDER_INFO[
|
||||
selectedProvider
|
||||
@@ -528,7 +556,7 @@ export function ModelConfigDialog({
|
||||
].label
|
||||
}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedProvider.models
|
||||
.length === 0
|
||||
? dict.modelConfig
|
||||
@@ -545,8 +573,8 @@ export function ModelConfigDialog({
|
||||
</p>
|
||||
</div>
|
||||
{selectedProvider.validated && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
|
||||
<Check className="h-3.5 w-3.5 animate-check-pop" />
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">
|
||||
{
|
||||
dict.modelConfig
|
||||
@@ -558,13 +586,18 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
|
||||
{/* Configuration Section */}
|
||||
<ConfigSection
|
||||
title={
|
||||
dict.modelConfig.configuration
|
||||
}
|
||||
icon={Settings2}
|
||||
>
|
||||
<ConfigCard>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span>
|
||||
{
|
||||
dict.modelConfig
|
||||
.configuration
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4 space-y-4">
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -823,7 +856,7 @@ export function ModelConfigDialog({
|
||||
"h-9 px-4",
|
||||
validationStatus ===
|
||||
"success" &&
|
||||
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
||||
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||
)}
|
||||
>
|
||||
{validationStatus ===
|
||||
@@ -832,7 +865,7 @@ export function ModelConfigDialog({
|
||||
) : validationStatus ===
|
||||
"success" ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
@@ -942,7 +975,7 @@ export function ModelConfigDialog({
|
||||
"h-9 px-4",
|
||||
validationStatus ===
|
||||
"success" &&
|
||||
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
||||
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||
)}
|
||||
>
|
||||
{validationStatus ===
|
||||
@@ -951,7 +984,7 @@ export function ModelConfigDialog({
|
||||
) : validationStatus ===
|
||||
"success" ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
{
|
||||
dict
|
||||
.modelConfig
|
||||
@@ -1020,19 +1053,26 @@ export function ModelConfigDialog({
|
||||
.modelConfig
|
||||
.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ConfigCard>
|
||||
</ConfigSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models Section */}
|
||||
<ConfigSection
|
||||
title={dict.modelConfig.models}
|
||||
icon={Sparkles}
|
||||
action={
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>
|
||||
{
|
||||
dict.modelConfig
|
||||
.models
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -1048,6 +1088,7 @@ export function ModelConfigDialog({
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
// Clear duplicate error when typing
|
||||
if (
|
||||
duplicateError
|
||||
) {
|
||||
@@ -1076,11 +1117,12 @@ export function ModelConfigDialog({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"h-8 w-44 rounded-lg font-mono text-xs",
|
||||
"h-8 w-48 font-mono text-xs",
|
||||
duplicateError &&
|
||||
"border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
/>
|
||||
{/* Show duplicate error for custom model input */}
|
||||
{duplicateError && (
|
||||
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
|
||||
{duplicateError}
|
||||
@@ -1090,7 +1132,7 @@ export function ModelConfigDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-lg"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
if (
|
||||
customModelInput.trim()
|
||||
@@ -1127,7 +1169,7 @@ export function ModelConfigDialog({
|
||||
0
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
||||
<SelectTrigger className="w-32 h-8 hover:bg-accent">
|
||||
<span className="text-xs">
|
||||
{availableSuggestions.length ===
|
||||
0
|
||||
@@ -1160,14 +1202,14 @@ export function ModelConfigDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
</div>
|
||||
|
||||
{/* Model List */}
|
||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
||||
<div className="rounded-xl border bg-card overflow-hidden min-h-[120px]">
|
||||
{selectedProvider.models
|
||||
.length === 0 ? (
|
||||
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
||||
<div className="p-4 text-center h-full flex flex-col items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-2">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -1178,7 +1220,7 @@ export function ModelConfigDialog({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
<div className="divide-y">
|
||||
{selectedProvider.models.map(
|
||||
(model, index) => (
|
||||
<div
|
||||
@@ -1186,7 +1228,16 @@ export function ModelConfigDialog({
|
||||
model.id
|
||||
}
|
||||
className={cn(
|
||||
"transition-colors duration-150 hover:bg-interactive-hover/50",
|
||||
"transition-colors hover:bg-muted/30",
|
||||
index ===
|
||||
0 &&
|
||||
"rounded-t-xl",
|
||||
index ===
|
||||
selectedProvider
|
||||
.models
|
||||
.length -
|
||||
1 &&
|
||||
"rounded-b-xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3 min-w-0">
|
||||
@@ -1213,8 +1264,8 @@ export function ModelConfigDialog({
|
||||
) : model.validated ===
|
||||
true ? (
|
||||
// Valid
|
||||
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
) : model.validated ===
|
||||
false ? (
|
||||
@@ -1415,7 +1466,7 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="pt-4">
|
||||
@@ -1425,7 +1476,7 @@ export function ModelConfigDialog({
|
||||
onClick={() =>
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{
|
||||
@@ -1439,10 +1490,10 @@ export function ModelConfigDialog({
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-4">
|
||||
<Server className="h-8 w-8 text-primary/60" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg tracking-tight mb-1">
|
||||
<h3 className="font-semibold mb-1">
|
||||
{dict.modelConfig.configureProviders}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
@@ -1454,7 +1505,7 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
|
||||
<div className="px-6 py-3 border-t bg-muted/20">
|
||||
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
|
||||
<Key className="h-3 w-3" />
|
||||
{dict.modelConfig.apiKeyStored}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ModelSelector({
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ModelSelectorList>
|
||||
<ModelSelectorEmpty>
|
||||
{validatedModels.length === 0 && models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
|
||||
@@ -24,32 +24,6 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
@@ -203,154 +177,145 @@ function SettingsContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
<DialogDescription>
|
||||
{dict.settings.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* Access Code (conditional) */}
|
||||
{accessCodeRequired && (
|
||||
<div className="py-4 first:pt-0 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="access-code"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
dict.settings.accessCodePlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 px-4 rounded-xl"
|
||||
>
|
||||
{isVerifying ? "..." : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem
|
||||
label={dict.settings.language}
|
||||
description={dict.settings.languageDescription}
|
||||
>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
<div className="space-y-4 py-2">
|
||||
{accessCodeRequired && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="access-code">
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) => setAccessCode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
dict.settings.accessCodePlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
{isVerifying ? "..." : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-[0.8rem] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme */}
|
||||
<SettingItem
|
||||
label={dict.settings.theme}
|
||||
description={dict.settings.themeDescription}
|
||||
>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="language-select">
|
||||
{dict.settings.language}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.languageDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={currentLang} onValueChange={changeLanguage}>
|
||||
<SelectTrigger id="language-select" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={`${dict.settings.drawioStyleDescription} ${
|
||||
drawioUi === "min"
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch
|
||||
}`}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="theme-toggle">
|
||||
{dict.settings.theme}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.themeDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
onClick={onToggleDrawioUi}
|
||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
||||
>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="drawio-ui">
|
||||
{dict.settings.drawioStyle}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.drawioStyleDescription}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Close Protection */}
|
||||
<SettingItem
|
||||
label={dict.settings.closeProtection}
|
||||
description={dict.settings.closeProtectionDescription}
|
||||
? dict.settings.minimal
|
||||
: dict.settings.sketch}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="drawio-ui"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleDrawioUi}
|
||||
>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={(checked) => {
|
||||
setCloseProtection(checked)
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
checked.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
{dict.settings.switchTo}{" "}
|
||||
{drawioUi === "min"
|
||||
? dict.settings.sketch
|
||||
: dict.settings.minimal}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="close-protection">
|
||||
{dict.settings.closeProtection}
|
||||
</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
{dict.settings.closeProtectionDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="close-protection"
|
||||
checked={closeProtection}
|
||||
onCheckedChange={(checked) => {
|
||||
setCloseProtection(checked)
|
||||
localStorage.setItem(
|
||||
STORAGE_CLOSE_PROTECTION_KEY,
|
||||
checked.toString(),
|
||||
)
|
||||
onCloseProtectionChange?.(checked)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<p className="text-[0.75rem] text-muted-foreground text-center">
|
||||
Version {process.env.APP_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
@@ -363,9 +328,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<DialogContent className="sm:max-w-lg p-0">
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
}
|
||||
|
||||
@@ -38,10 +38,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,32 +57,13 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
// Base styles
|
||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||
"grid gap-4 p-6",
|
||||
// Refined visual treatment
|
||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||
// Entry/exit animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className={cn(
|
||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
||||
"text-muted-foreground/60 hover:text-foreground",
|
||||
"hover:bg-interactive-hover",
|
||||
"transition-all duration-150",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"disabled:pointer-events-none",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
||||
)}>
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -124,10 +102,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-xl font-semibold tracking-tight leading-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -140,10 +115,7 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground leading-relaxed",
|
||||
className
|
||||
)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
// Base styles
|
||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
||||
"border border-border-subtle bg-surface-1",
|
||||
"text-sm text-foreground",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground/60",
|
||||
// Selection
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// Transitions
|
||||
"transition-all duration-150 ease-out",
|
||||
// Hover state
|
||||
"hover:border-border-default",
|
||||
// Focus state - refined ring
|
||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
||||
// File input
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
||||
"file:bg-transparent file:text-sm file:font-medium",
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Invalid state
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
"dark:aria-invalid:ring-destructive/40",
|
||||
// Dark mode background
|
||||
"dark:bg-surface-1",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
47
electron.d.ts
vendored
47
electron.d.ts
vendored
@@ -2,6 +2,29 @@
|
||||
* 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 {
|
||||
interface Window {
|
||||
/** Main window Electron API */
|
||||
@@ -23,7 +46,29 @@ declare global {
|
||||
/** Save data to file via save dialog */
|
||||
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 {}
|
||||
export { ConfigPreset, ApplyPresetResult }
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||
import {
|
||||
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
|
||||
@@ -9,6 +24,13 @@ export function buildAppMenu(): void {
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the menu (call this when presets change)
|
||||
*/
|
||||
export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template
|
||||
*/
|
||||
@@ -24,6 +46,15 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Settings...",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
@@ -38,7 +69,22 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
// File menu
|
||||
template.push({
|
||||
label: "File",
|
||||
submenu: [isMac ? { role: "close" } : { role: "quit" }],
|
||||
submenu: [
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Settings",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
showSettingsWindow(win || undefined)
|
||||
},
|
||||
},
|
||||
{ type: "separator" } as MenuItemConstructorOptions,
|
||||
]),
|
||||
isMac ? { role: "close" } : { role: "quit" },
|
||||
],
|
||||
})
|
||||
|
||||
// Edit menu
|
||||
@@ -83,6 +129,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
],
|
||||
})
|
||||
|
||||
// Configuration menu with presets
|
||||
template.push(buildConfigMenu())
|
||||
|
||||
// Window menu
|
||||
template.push({
|
||||
label: "Window",
|
||||
@@ -123,3 +172,70 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
460
electron/main/config-manager.ts
Normal file
460
electron/main/config-manager.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
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,8 +1,10 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import { createWindow, getMainWindow } from "./window-manager"
|
||||
|
||||
// Single instance lock
|
||||
@@ -22,12 +24,19 @@ if (!gotTheLock) {
|
||||
// Load environment variables from .env files
|
||||
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"
|
||||
let serverUrl: string | null = null
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
|
||||
// Build application menu
|
||||
buildAppMenu()
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
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
|
||||
@@ -84,4 +123,90 @@ export function registerIpcHandlers(): void {
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
78
electron/main/settings-window.ts
Normal file
78
electron/main/settings-window.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
35
electron/preload/settings.ts
Normal file
35
electron/preload/settings.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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"),
|
||||
})
|
||||
110
electron/settings/index.html
Normal file
110
electron/settings/index.html
Normal file
@@ -0,0 +1,110 @@
|
||||
<!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>
|
||||
311
electron/settings/settings.css
Normal file
311
electron/settings/settings.css
Normal file
@@ -0,0 +1,311 @@
|
||||
: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;
|
||||
}
|
||||
311
electron/settings/settings.js
Normal file
311
electron/settings/settings.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -14,14 +14,22 @@ export function register() {
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||
// Whitelist approach: only export AI-related spans
|
||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
||||
shouldExportSpan: ({ otelSpan }) => {
|
||||
const spanName = otelSpan.name
|
||||
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
|
||||
if (spanName === "chat" || spanName.startsWith("ai.")) {
|
||||
return true
|
||||
// Skip Next.js HTTP infrastructure spans
|
||||
if (
|
||||
spanName.startsWith("POST") ||
|
||||
spanName.startsWith("GET") ||
|
||||
spanName.startsWith("RSC") ||
|
||||
spanName.includes("BaseServer") ||
|
||||
spanName.includes("handleRequest") ||
|
||||
spanName.includes("resolve page") ||
|
||||
spanName.includes("start response")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -95,8 +95,8 @@ function parseIntSafe(
|
||||
* Supports various AI SDK providers with their unique configuration options
|
||||
*
|
||||
* Environment variables:
|
||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5
|
||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5
|
||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
|
||||
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||
@@ -118,19 +118,18 @@ function buildProviderOptions(
|
||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
||||
|
||||
// OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts
|
||||
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
|
||||
if (
|
||||
modelId &&
|
||||
(modelId.includes("o1") ||
|
||||
modelId.includes("o3") ||
|
||||
modelId.includes("o4") ||
|
||||
modelId.includes("gpt-5"))
|
||||
) {
|
||||
options.openai = {
|
||||
// Auto-enable reasoning summary for reasoning models
|
||||
// Use 'auto' as default since not all models support 'detailed'
|
||||
// Auto-enable reasoning summary for reasoning models (default: detailed)
|
||||
reasoningSummary:
|
||||
(reasoningSummary as "auto" | "detailed") || "auto",
|
||||
(reasoningSummary as "none" | "brief" | "detailed") ||
|
||||
"detailed",
|
||||
}
|
||||
|
||||
// Optionally configure reasoning effort
|
||||
@@ -153,7 +152,8 @@ function buildProviderOptions(
|
||||
}
|
||||
if (reasoningSummary) {
|
||||
options.openai.reasoningSummary = reasoningSummary as
|
||||
| "auto"
|
||||
| "none"
|
||||
| "brief"
|
||||
| "detailed"
|
||||
}
|
||||
}
|
||||
@@ -588,16 +588,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
case "openai": {
|
||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||
if (baseURL) {
|
||||
// Custom base URL = third-party proxy, use Chat Completions API
|
||||
// for compatibility (most proxies don't support /responses endpoint)
|
||||
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
const customOpenAI = createOpenAI({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
})
|
||||
model = customOpenAI.chat(modelId)
|
||||
} else if (overrides?.apiKey) {
|
||||
// Custom API key but official OpenAI endpoint, use Responses API
|
||||
// to support reasoning for gpt-5, o1, o3, o4 models
|
||||
const customOpenAI = createOpenAI({ apiKey })
|
||||
model = customOpenAI(modelId)
|
||||
} else {
|
||||
model = openai(modelId)
|
||||
}
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import {
|
||||
ConditionalCheckFailedException,
|
||||
DynamoDBClient,
|
||||
GetItemCommand,
|
||||
UpdateItemCommand,
|
||||
} from "@aws-sdk/client-dynamodb"
|
||||
|
||||
// Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set
|
||||
// OSS users who don't need quota tracking can simply not set this env var
|
||||
const TABLE = process.env.DYNAMODB_QUOTA_TABLE
|
||||
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
|
||||
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
|
||||
// Defaults to UTC if not set
|
||||
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
|
||||
|
||||
// Validate timezone at module load
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
|
||||
new Date(),
|
||||
)
|
||||
} catch {
|
||||
console.warn(
|
||||
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
|
||||
)
|
||||
QUOTA_TIMEZONE = "UTC"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
||||
*/
|
||||
function getTodayInTimezone(): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: QUOTA_TIMEZONE,
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
// Only create client if quota is enabled
|
||||
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
|
||||
|
||||
/**
|
||||
* Check if server-side quota tracking is enabled.
|
||||
* Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set.
|
||||
*/
|
||||
export function isQuotaEnabled(): boolean {
|
||||
return !!TABLE
|
||||
}
|
||||
|
||||
interface QuotaLimits {
|
||||
requests: number // Daily request limit
|
||||
tokens: number // Daily token limit
|
||||
tpm: number // Tokens per minute
|
||||
}
|
||||
|
||||
interface QuotaCheckResult {
|
||||
allowed: boolean
|
||||
error?: string
|
||||
type?: "request" | "token" | "tpm"
|
||||
used?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all quotas and increment request count atomically.
|
||||
* Uses ConditionExpression to prevent race conditions.
|
||||
* Returns which limit was exceeded if any.
|
||||
*/
|
||||
export async function checkAndIncrementRequest(
|
||||
ip: string,
|
||||
limits: QuotaLimits,
|
||||
): Promise<QuotaCheckResult> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) {
|
||||
return { allowed: true }
|
||||
}
|
||||
|
||||
const today = getTodayInTimezone()
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||
|
||||
try {
|
||||
// First, try to reset counts if it's a new day (atomic day reset)
|
||||
// This will succeed only if lastResetDate < today or doesn't exist
|
||||
try {
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
// Reset all counts to 1/0 for the new day
|
||||
UpdateExpression: `
|
||||
SET lastResetDate = :today,
|
||||
dailyReqCount = :one,
|
||||
dailyTokenCount = :zero,
|
||||
lastMinute = :minute,
|
||||
tpmCount = :zero,
|
||||
#ttl = :ttl
|
||||
`,
|
||||
// Only succeed if it's a new day (or new item)
|
||||
ConditionExpression: `
|
||||
attribute_not_exists(lastResetDate) OR lastResetDate < :today
|
||||
`,
|
||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||
ExpressionAttributeValues: {
|
||||
":today": { S: today },
|
||||
":zero": { N: "0" },
|
||||
":one": { N: "1" },
|
||||
":minute": { S: currentMinute },
|
||||
":ttl": { N: String(ttl) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
// New day reset successful
|
||||
return { allowed: true }
|
||||
} catch (resetError: any) {
|
||||
// If condition failed, it's the same day - continue to increment logic
|
||||
if (!(resetError instanceof ConditionalCheckFailedException)) {
|
||||
throw resetError // Re-throw unexpected errors
|
||||
}
|
||||
}
|
||||
|
||||
// Same day - increment request count with limit checks
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
// Increment request count, handle minute boundary for TPM
|
||||
UpdateExpression: `
|
||||
SET lastMinute = :minute,
|
||||
tpmCount = if_not_exists(tpmCount, :zero),
|
||||
#ttl = :ttl
|
||||
ADD dailyReqCount :one
|
||||
`,
|
||||
// Check all limits before allowing increment
|
||||
ConditionExpression: `
|
||||
lastResetDate = :today AND
|
||||
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
|
||||
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
|
||||
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
||||
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||
`,
|
||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||
ExpressionAttributeValues: {
|
||||
":today": { S: today },
|
||||
":zero": { N: "0" },
|
||||
":one": { N: "1" },
|
||||
":minute": { S: currentMinute },
|
||||
":ttl": { N: String(ttl) },
|
||||
":reqLimit": { N: String(limits.requests || 999999) },
|
||||
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||
":tpmLimit": { N: String(limits.tpm || 999999) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return { allowed: true }
|
||||
} catch (e: any) {
|
||||
// Condition failed - need to determine which limit was exceeded
|
||||
if (e instanceof ConditionalCheckFailedException) {
|
||||
// Get current counts to determine which limit was hit
|
||||
try {
|
||||
const getResult = await client.send(
|
||||
new GetItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
}),
|
||||
)
|
||||
|
||||
const item = getResult.Item
|
||||
const storedDate = item?.lastResetDate?.S
|
||||
const storedMinute = item?.lastMinute?.S
|
||||
const isNewDay = !storedDate || storedDate < today
|
||||
|
||||
const dailyReqCount = isNewDay
|
||||
? 0
|
||||
: Number(item?.dailyReqCount?.N || 0)
|
||||
const dailyTokenCount = isNewDay
|
||||
? 0
|
||||
: Number(item?.dailyTokenCount?.N || 0)
|
||||
const tpmCount =
|
||||
storedMinute !== currentMinute
|
||||
? 0
|
||||
: Number(item?.tpmCount?.N || 0)
|
||||
|
||||
// Determine which limit was exceeded
|
||||
if (limits.requests > 0 && dailyReqCount >= limits.requests) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "request",
|
||||
error: "Daily request limit exceeded",
|
||||
used: dailyReqCount,
|
||||
limit: limits.requests,
|
||||
}
|
||||
}
|
||||
if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "token",
|
||||
error: "Daily token limit exceeded",
|
||||
used: dailyTokenCount,
|
||||
limit: limits.tokens,
|
||||
}
|
||||
}
|
||||
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
|
||||
return {
|
||||
allowed: false,
|
||||
type: "tpm",
|
||||
error: "Rate limit exceeded (tokens per minute)",
|
||||
used: tpmCount,
|
||||
limit: limits.tpm,
|
||||
}
|
||||
}
|
||||
|
||||
// Condition failed but no limit clearly exceeded - race condition edge case
|
||||
// Fail safe by allowing (could be a reset race)
|
||||
console.warn(
|
||||
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
||||
)
|
||||
return { allowed: true }
|
||||
} catch (getError: any) {
|
||||
console.error(
|
||||
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
|
||||
)
|
||||
return { allowed: true } // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
// Other DynamoDB errors - fail open
|
||||
console.error(
|
||||
`[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`,
|
||||
)
|
||||
return { allowed: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage after response completes.
|
||||
* Uses atomic operations to update both daily token count and TPM count.
|
||||
* Handles minute boundaries atomically to prevent race conditions.
|
||||
*/
|
||||
export async function recordTokenUsage(
|
||||
ip: string,
|
||||
tokens: number,
|
||||
): Promise<void> {
|
||||
// Skip if quota tracking not enabled
|
||||
if (!client || !TABLE) return
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return
|
||||
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||
|
||||
try {
|
||||
// Try to update assuming same minute (most common case)
|
||||
// Uses condition to ensure we're in the same minute
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
UpdateExpression:
|
||||
"SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens",
|
||||
ConditionExpression: "lastMinute = :minute",
|
||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||
ExpressionAttributeValues: {
|
||||
":minute": { S: currentMinute },
|
||||
":tokens": { N: String(tokens) },
|
||||
":ttl": { N: String(ttl) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (e: any) {
|
||||
if (e instanceof ConditionalCheckFailedException) {
|
||||
// Different minute - reset TPM count and set new minute
|
||||
try {
|
||||
await client.send(
|
||||
new UpdateItemCommand({
|
||||
TableName: TABLE,
|
||||
Key: { PK: { S: `IP#${ip}` } },
|
||||
UpdateExpression:
|
||||
"SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens",
|
||||
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||
ExpressionAttributeValues: {
|
||||
":minute": { S: currentMinute },
|
||||
":tokens": { N: String(tokens) },
|
||||
":ttl": { N: String(ttl) },
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (retryError: any) {
|
||||
console.error(
|
||||
`[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,9 @@ export function getLangfuseClient(): LangfuseClient | null {
|
||||
return langfuseClient
|
||||
}
|
||||
|
||||
// Check if Langfuse is configured (both keys required)
|
||||
// Check if Langfuse is configured
|
||||
export function isLangfuseEnabled(): boolean {
|
||||
return !!(
|
||||
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
|
||||
)
|
||||
return !!process.env.LANGFUSE_PUBLIC_KEY
|
||||
}
|
||||
|
||||
// Update trace with input data at the start of request
|
||||
@@ -45,16 +43,34 @@ export function setTraceInput(params: {
|
||||
}
|
||||
|
||||
// Update trace with output and end the span
|
||||
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
|
||||
// so we only need to set the output text and close our wrapper span
|
||||
export function setTraceOutput(output: string) {
|
||||
export function setTraceOutput(
|
||||
output: string,
|
||||
usage?: { promptTokens?: number; completionTokens?: number },
|
||||
) {
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
updateActiveTrace({ output })
|
||||
|
||||
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
|
||||
const activeSpan = api.trace.getActiveSpan()
|
||||
if (activeSpan) {
|
||||
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
|
||||
if (usage?.promptTokens) {
|
||||
activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.input_tokens",
|
||||
usage.promptTokens,
|
||||
)
|
||||
}
|
||||
if (usage?.completionTokens) {
|
||||
activeSpan.setAttribute(
|
||||
"ai.usage.completionTokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
activeSpan.setAttribute(
|
||||
"gen_ai.usage.output_tokens",
|
||||
usage.completionTokens,
|
||||
)
|
||||
}
|
||||
activeSpan.end()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 delete: only cell_id is needed
|
||||
- Find the cell_id from "Current diagram XML" in system context
|
||||
- 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": [{"operation": "delete", "cell_id": "5"}]}
|
||||
- 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>"}]}
|
||||
- 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 delete: {"operations": [{"type": "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>"}]}
|
||||
|
||||
⚠️ 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
|
||||
{
|
||||
"operations": [
|
||||
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||
{"operation": "delete", "cell_id": "5"}
|
||||
{"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
|
||||
{"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
|
||||
{"type": "delete", "cell_id": "5"}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
@@ -293,17 +293,17 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
||||
|
||||
Change label:
|
||||
\`\`\`json
|
||||
{"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>"}]}
|
||||
{"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>"}]}
|
||||
\`\`\`
|
||||
|
||||
Add new shape:
|
||||
\`\`\`json
|
||||
{"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>"}]}
|
||||
{"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>"}]}
|
||||
\`\`\`
|
||||
|
||||
Delete cell:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
{"operations": [{"type": "delete", "cell_id": "5"}]}
|
||||
\`\`\`
|
||||
|
||||
**Error Recovery:**
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { QuotaLimitToast } from "@/components/quota-limit-toast"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
export interface QuotaConfig {
|
||||
dailyRequestLimit: number
|
||||
@@ -12,45 +13,181 @@ export interface QuotaConfig {
|
||||
tpmLimit: number
|
||||
}
|
||||
|
||||
export interface QuotaCheckResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
used: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for displaying quota limit toasts.
|
||||
* Server-side handles actual quota enforcement via DynamoDB.
|
||||
* This hook only provides UI feedback when limits are exceeded.
|
||||
* Hook for managing request/token quotas and rate limiting.
|
||||
* Handles three types of limits:
|
||||
* - Daily request limit
|
||||
* - Daily token limit
|
||||
* - Tokens per minute (TPM) rate limit
|
||||
*
|
||||
* Users with their own API key bypass all limits.
|
||||
*/
|
||||
export function useQuotaManager(config: QuotaConfig): {
|
||||
showQuotaLimitToast: (used?: number, limit?: number) => void
|
||||
showTokenLimitToast: (used?: number, limit?: number) => void
|
||||
showTPMLimitToast: (limit?: number) => void
|
||||
hasOwnApiKey: () => boolean
|
||||
checkDailyLimit: () => QuotaCheckResult
|
||||
checkTokenLimit: () => QuotaCheckResult
|
||||
checkTPMLimit: () => QuotaCheckResult
|
||||
incrementRequestCount: () => void
|
||||
incrementTokenCount: (tokens: number) => void
|
||||
incrementTPMCount: (tokens: number) => void
|
||||
showQuotaLimitToast: () => void
|
||||
showTokenLimitToast: (used: number) => void
|
||||
showTPMLimitToast: () => void
|
||||
} {
|
||||
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
|
||||
|
||||
const dict = useDictionary()
|
||||
|
||||
// Show quota limit toast (request-based)
|
||||
const showQuotaLimitToast = useCallback(
|
||||
(used?: number, limit?: number) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
used={used ?? dailyRequestLimit}
|
||||
limit={limit ?? dailyRequestLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
// Check if user has their own API key configured (bypass limits)
|
||||
const hasOwnApiKey = useCallback((): boolean => {
|
||||
const provider = localStorage.getItem(STORAGE_KEYS.aiProvider)
|
||||
const apiKey = localStorage.getItem(STORAGE_KEYS.aiApiKey)
|
||||
return !!(provider && apiKey)
|
||||
}, [])
|
||||
|
||||
// Generic helper: Parse count from localStorage with NaN guard
|
||||
const parseStorageCount = (key: string): number => {
|
||||
const count = parseInt(localStorage.getItem(key) || "0", 10)
|
||||
return Number.isNaN(count) ? 0 : count
|
||||
}
|
||||
|
||||
// Generic helper: Create quota checker factory
|
||||
const createQuotaChecker = useCallback(
|
||||
(
|
||||
getTimeKey: () => string,
|
||||
timeStorageKey: string,
|
||||
countStorageKey: string,
|
||||
limit: number,
|
||||
) => {
|
||||
return (): QuotaCheckResult => {
|
||||
if (hasOwnApiKey())
|
||||
return { allowed: true, remaining: -1, used: 0 }
|
||||
if (limit <= 0) return { allowed: true, remaining: -1, used: 0 }
|
||||
|
||||
const currentTime = getTimeKey()
|
||||
const storedTime = localStorage.getItem(timeStorageKey)
|
||||
let count = parseStorageCount(countStorageKey)
|
||||
|
||||
if (storedTime !== currentTime) {
|
||||
count = 0
|
||||
localStorage.setItem(timeStorageKey, currentTime)
|
||||
localStorage.setItem(countStorageKey, "0")
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: count < limit,
|
||||
remaining: limit - count,
|
||||
used: count,
|
||||
}
|
||||
}
|
||||
},
|
||||
[dailyRequestLimit],
|
||||
[hasOwnApiKey],
|
||||
)
|
||||
|
||||
// Generic helper: Create quota incrementer factory
|
||||
const createQuotaIncrementer = useCallback(
|
||||
(
|
||||
getTimeKey: () => string,
|
||||
timeStorageKey: string,
|
||||
countStorageKey: string,
|
||||
validateInput: boolean = false,
|
||||
) => {
|
||||
return (tokens: number = 1): void => {
|
||||
if (validateInput && (!Number.isFinite(tokens) || tokens <= 0))
|
||||
return
|
||||
|
||||
const currentTime = getTimeKey()
|
||||
const storedTime = localStorage.getItem(timeStorageKey)
|
||||
let count = parseStorageCount(countStorageKey)
|
||||
|
||||
if (storedTime !== currentTime) {
|
||||
count = 0
|
||||
localStorage.setItem(timeStorageKey, currentTime)
|
||||
}
|
||||
|
||||
localStorage.setItem(countStorageKey, String(count + tokens))
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Check daily request limit
|
||||
const checkDailyLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.requestDate,
|
||||
STORAGE_KEYS.requestCount,
|
||||
dailyRequestLimit,
|
||||
),
|
||||
[createQuotaChecker, dailyRequestLimit],
|
||||
)
|
||||
|
||||
// Increment request count
|
||||
const incrementRequestCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.requestDate,
|
||||
STORAGE_KEYS.requestCount,
|
||||
false,
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
)
|
||||
|
||||
// Show quota limit toast (request-based)
|
||||
const showQuotaLimitToast = useCallback(() => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
used={dailyRequestLimit}
|
||||
limit={dailyRequestLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
}, [dailyRequestLimit])
|
||||
|
||||
// Check daily token limit
|
||||
const checkTokenLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.tokenDate,
|
||||
STORAGE_KEYS.tokenCount,
|
||||
dailyTokenLimit,
|
||||
),
|
||||
[createQuotaChecker, dailyTokenLimit],
|
||||
)
|
||||
|
||||
// Increment token count
|
||||
const incrementTokenCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => new Date().toDateString(),
|
||||
STORAGE_KEYS.tokenDate,
|
||||
STORAGE_KEYS.tokenCount,
|
||||
true, // Validate input tokens
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
)
|
||||
|
||||
// Show token limit toast
|
||||
const showTokenLimitToast = useCallback(
|
||||
(used?: number, limit?: number) => {
|
||||
(used: number) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<QuotaLimitToast
|
||||
type="token"
|
||||
used={used ?? dailyTokenLimit}
|
||||
limit={limit ?? dailyTokenLimit}
|
||||
used={used}
|
||||
limit={dailyTokenLimit}
|
||||
onDismiss={() => toast.dismiss(t)}
|
||||
/>
|
||||
),
|
||||
@@ -60,24 +197,54 @@ export function useQuotaManager(config: QuotaConfig): {
|
||||
[dailyTokenLimit],
|
||||
)
|
||||
|
||||
// Show TPM limit toast
|
||||
const showTPMLimitToast = useCallback(
|
||||
(limit?: number) => {
|
||||
const effectiveLimit = limit ?? tpmLimit
|
||||
const limitDisplay =
|
||||
effectiveLimit >= 1000
|
||||
? `${effectiveLimit / 1000}k`
|
||||
: String(effectiveLimit)
|
||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
||||
limit: limitDisplay,
|
||||
seconds: 60,
|
||||
})
|
||||
toast.error(message, { duration: 8000 })
|
||||
},
|
||||
[tpmLimit, dict],
|
||||
// Check TPM (tokens per minute) limit
|
||||
const checkTPMLimit = useMemo(
|
||||
() =>
|
||||
createQuotaChecker(
|
||||
() => Math.floor(Date.now() / 60000).toString(),
|
||||
STORAGE_KEYS.tpmMinute,
|
||||
STORAGE_KEYS.tpmCount,
|
||||
tpmLimit,
|
||||
),
|
||||
[createQuotaChecker, tpmLimit],
|
||||
)
|
||||
|
||||
// Increment TPM count
|
||||
const incrementTPMCount = useMemo(
|
||||
() =>
|
||||
createQuotaIncrementer(
|
||||
() => Math.floor(Date.now() / 60000).toString(),
|
||||
STORAGE_KEYS.tpmMinute,
|
||||
STORAGE_KEYS.tpmCount,
|
||||
true, // Validate input tokens
|
||||
),
|
||||
[createQuotaIncrementer],
|
||||
)
|
||||
|
||||
// Show TPM limit toast
|
||||
const showTPMLimitToast = useCallback(() => {
|
||||
const limitDisplay =
|
||||
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
|
||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
||||
limit: limitDisplay,
|
||||
seconds: 60,
|
||||
})
|
||||
toast.error(message, { duration: 8000 })
|
||||
}, [tpmLimit, dict])
|
||||
|
||||
return {
|
||||
// Check functions
|
||||
hasOwnApiKey,
|
||||
checkDailyLimit,
|
||||
checkTokenLimit,
|
||||
checkTPMLimit,
|
||||
|
||||
// Increment functions
|
||||
incrementRequestCount,
|
||||
incrementTokenCount,
|
||||
incrementTPMCount,
|
||||
|
||||
// Toast functions
|
||||
showQuotaLimitToast,
|
||||
showTokenLimitToast,
|
||||
showTPMLimitToast,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 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")}`
|
||||
}
|
||||
206
lib/utils.ts
206
lib/utils.ts
@@ -36,73 +36,29 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
||||
/**
|
||||
* Check if mxCell XML output is complete (not truncated).
|
||||
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||
* Also handles function-calling wrapper tags that may be incorrectly included.
|
||||
* @param xml - The XML string to check (can be undefined/null)
|
||||
* @returns true if XML appears complete, false if truncated or empty
|
||||
*/
|
||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||
const trimmed = xml?.trim() || ""
|
||||
let trimmed = xml?.trim() || ""
|
||||
if (!trimmed) return false
|
||||
|
||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||
|
||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||
|
||||
// No valid ending found at all
|
||||
if (lastValidEnd === -1) return false
|
||||
|
||||
// Check what comes after the last valid ending
|
||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||
|
||||
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only complete mxCell elements from partial/streaming XML.
|
||||
* This allows progressive rendering during streaming by ignoring incomplete trailing elements.
|
||||
* @param xml - The partial XML string (may contain incomplete trailing mxCell)
|
||||
* @returns XML string containing only complete mxCell elements
|
||||
*/
|
||||
export function extractCompleteMxCells(xml: string | undefined | null): string {
|
||||
if (!xml) return ""
|
||||
|
||||
const completeCells: Array<{ index: number; text: string }> = []
|
||||
|
||||
// Match self-closing mxCell tags: <mxCell ... />
|
||||
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
|
||||
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
|
||||
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
|
||||
|
||||
// Find all self-closing mxCell elements
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = selfClosingPattern.exec(xml)) !== null) {
|
||||
completeCells.push({ index: match.index, text: match[0] })
|
||||
// Strip Anthropic function-calling wrapper tags if present
|
||||
// These can leak into tool input due to AI SDK parsing issues
|
||||
// Use loop because tags are nested: </mxCell></mxParameter></invoke>
|
||||
let prev = ""
|
||||
while (prev !== trimmed) {
|
||||
prev = trimmed
|
||||
trimmed = trimmed
|
||||
.replace(/<\/mxParameter>\s*$/i, "")
|
||||
.replace(/<\/invoke>\s*$/i, "")
|
||||
.replace(/<\/antml:parameter>\s*$/i, "")
|
||||
.replace(/<\/antml:invoke>\s*$/i, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
// Find all mxCell elements with nested content (like mxGeometry)
|
||||
while ((match = nestedPattern.exec(xml)) !== null) {
|
||||
completeCells.push({ index: match.index, text: match[0] })
|
||||
}
|
||||
|
||||
// Sort by position to maintain order
|
||||
completeCells.sort((a, b) => a.index - b.index)
|
||||
|
||||
// Remove duplicates (a self-closing match might overlap with nested match)
|
||||
const seen = new Set<number>()
|
||||
const uniqueCells = completeCells.filter((cell) => {
|
||||
if (seen.has(cell.index)) return false
|
||||
seen.add(cell.index)
|
||||
return true
|
||||
})
|
||||
|
||||
return uniqueCells.map((c) => c.text).join("\n")
|
||||
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -265,21 +221,6 @@ export function convertToLegalXml(xmlString: string): string {
|
||||
"&",
|
||||
)
|
||||
|
||||
// Fix unescaped < and > in attribute values for XML parsing
|
||||
// HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped
|
||||
// This is critical because DOMParser will fail on unescaped < > in attributes
|
||||
if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) {
|
||||
cellContent = cellContent.replace(
|
||||
/=\s*"([^"]*)"/g,
|
||||
(_match, value) => {
|
||||
const escaped = value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `="${escaped}"`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Indent each line of the matched block for readability.
|
||||
const formatted = cellContent
|
||||
.split("\n")
|
||||
@@ -324,20 +265,6 @@ export function wrapWithMxFile(xml: string): string {
|
||||
content = xml.replace(/<\/?root>/g, "").trim()
|
||||
}
|
||||
|
||||
// Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.)
|
||||
// Find the last valid mxCell ending and remove everything after it
|
||||
const lastSelfClose = content.lastIndexOf("/>")
|
||||
const lastMxCellClose = content.lastIndexOf("</mxCell>")
|
||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||
if (lastValidEnd !== -1) {
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
const suffix = content.slice(lastValidEnd + endOffset)
|
||||
// If suffix is only closing tags (wrapper tags), strip it
|
||||
if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) {
|
||||
content = content.slice(0, lastValidEnd + endOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
|
||||
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
|
||||
content = content
|
||||
@@ -455,7 +382,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
|
||||
// ============================================================================
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
@@ -528,7 +455,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Process each operation
|
||||
for (const op of operations) {
|
||||
if (op.operation === "update") {
|
||||
if (op.type === "update") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
@@ -580,7 +507,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Update the map with the new element
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "add") {
|
||||
} else if (op.type === "add") {
|
||||
// Check if ID already exists
|
||||
if (cellMap.has(op.cell_id)) {
|
||||
errors.push({
|
||||
@@ -632,7 +559,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Add to map
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "delete") {
|
||||
} else if (op.type === "delete") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
@@ -942,21 +869,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Removed CDATA wrapper")
|
||||
}
|
||||
|
||||
// 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.)
|
||||
// These are closing tags after the last valid mxCell that break XML parsing
|
||||
const lastSelfClose = fixed.lastIndexOf("/>")
|
||||
const lastMxCellClose = fixed.lastIndexOf("</mxCell>")
|
||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||
if (lastValidEnd !== -1) {
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
const suffix = fixed.slice(lastValidEnd + endOffset)
|
||||
// If suffix contains only closing tags (wrapper tags) or whitespace, strip it
|
||||
if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) {
|
||||
fixed = fixed.slice(0, lastValidEnd + endOffset)
|
||||
fixes.push("Stripped trailing LLM wrapper tags")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
|
||||
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
||||
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
||||
@@ -1062,8 +974,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Removed quotes around color values in style")
|
||||
}
|
||||
|
||||
// 4. Fix unescaped < and > in attribute values
|
||||
// < is required to be escaped, > is not strictly required but we escape for consistency
|
||||
// 4. Fix unescaped < in attribute values
|
||||
// This is tricky - we need to find < inside quoted attribute values
|
||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||
let attrMatch
|
||||
let hasUnescapedLt = false
|
||||
@@ -1074,12 +986,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
}
|
||||
if (hasUnescapedLt) {
|
||||
// Replace < and > with < and > inside attribute values
|
||||
// Replace < with < inside attribute values
|
||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
||||
const escaped = value.replace(/</g, "<")
|
||||
return `="${escaped}"`
|
||||
})
|
||||
fixes.push("Escaped <> characters in attribute values")
|
||||
fixes.push("Escaped < characters in attribute values")
|
||||
}
|
||||
|
||||
// 5. Fix invalid character references (remove malformed ones)
|
||||
@@ -1167,8 +1079,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
|
||||
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
||||
// IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values
|
||||
// Tags like <b>, <br> inside value="<b>text</b>" should be preserved (they're HTML content)
|
||||
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
|
||||
const validDrawioTags = new Set([
|
||||
"mxfile",
|
||||
"diagram",
|
||||
@@ -1181,59 +1092,25 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
"Object",
|
||||
"mxRectangle",
|
||||
])
|
||||
|
||||
// Helper: Check if a position is inside a quoted attribute value
|
||||
// by counting unescaped quotes before that position
|
||||
const isInsideQuotes = (str: string, pos: number): boolean => {
|
||||
let inQuote = false
|
||||
let quoteChar = ""
|
||||
for (let i = 0; i < pos && i < str.length; i++) {
|
||||
const c = str[i]
|
||||
if (inQuote) {
|
||||
if (c === quoteChar) inQuote = false
|
||||
} else if (c === '"' || c === "'") {
|
||||
// Check if this quote is part of an attribute (preceded by =)
|
||||
// Look back for = sign
|
||||
let j = i - 1
|
||||
while (j >= 0 && /\s/.test(str[j])) j--
|
||||
if (j >= 0 && str[j] === "=") {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return inQuote
|
||||
}
|
||||
|
||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||
let foreignMatch
|
||||
const foreignTags = new Set<string>()
|
||||
const foreignTagPositions: Array<{
|
||||
tag: string
|
||||
start: number
|
||||
end: number
|
||||
}> = []
|
||||
|
||||
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
||||
const tagName = foreignMatch[1]
|
||||
// Skip if this is a valid draw.io tag
|
||||
if (validDrawioTags.has(tagName)) continue
|
||||
// Skip if this tag is inside a quoted attribute value
|
||||
if (isInsideQuotes(fixed, foreignMatch.index)) continue
|
||||
|
||||
foreignTags.add(tagName)
|
||||
foreignTagPositions.push({
|
||||
tag: tagName,
|
||||
start: foreignMatch.index,
|
||||
end: foreignMatch.index + foreignMatch[0].length,
|
||||
})
|
||||
if (!validDrawioTags.has(tagName)) {
|
||||
foreignTags.add(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
if (foreignTagPositions.length > 0) {
|
||||
// Remove tags from end to start to preserve indices
|
||||
foreignTagPositions.sort((a, b) => b.start - a.start)
|
||||
for (const { start, end } of foreignTagPositions) {
|
||||
fixed = fixed.slice(0, start) + fixed.slice(end)
|
||||
if (foreignTags.size > 0) {
|
||||
console.log(
|
||||
"[autoFixXml] Step 8c: Found foreign tags:",
|
||||
Array.from(foreignTags),
|
||||
)
|
||||
for (const tag of foreignTags) {
|
||||
// Remove opening tags (with or without attributes)
|
||||
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
|
||||
// Remove closing tags
|
||||
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
|
||||
}
|
||||
fixes.push(
|
||||
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
||||
@@ -1284,7 +1161,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
|
||||
// 10b. Remove extra closing tags (more closes than opens)
|
||||
// Need to properly count self-closing tags (they don't need closing tags)
|
||||
// IMPORTANT: Only count tags at element level, NOT inside quoted attribute values
|
||||
const tagCounts = new Map<
|
||||
string,
|
||||
{ opens: number; closes: number; selfClosing: number }
|
||||
@@ -1293,18 +1169,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
let tagCountMatch
|
||||
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
|
||||
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
|
||||
|
||||
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
||||
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
||||
const isClosing = tagPart.startsWith("/")
|
||||
const isSelfClosing = fullMatch.endsWith("/>")
|
||||
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
||||
|
||||
// Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming
|
||||
if (!validDrawioTags.has(tagName)) continue
|
||||
|
||||
let counts = tagCounts.get(tagName)
|
||||
if (!counts) {
|
||||
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
||||
|
||||
991
package-lock.json
generated
991
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
"prepare": "husky",
|
||||
"electron:dev": "node scripts/electron-dev.mjs",
|
||||
"electron:build": "npm run build && npm run electron:compile",
|
||||
"electron:compile": "npx esbuild electron/main/index.ts electron/preload/index.ts --bundle --platform=node --outdir=dist-electron --external:electron --sourcemap --packages=external",
|
||||
"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:start": "npx cross-env NODE_ENV=development npx electron .",
|
||||
"electron:prepare": "node scripts/prepare-electron-build.mjs",
|
||||
"dist": "npm run electron:build && npm run electron:prepare && npx electron-builder",
|
||||
@@ -32,7 +32,6 @@
|
||||
"@ai-sdk/google": "^3.0.0",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.1",
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@formatjs/intl-localematcher": "^0.7.2",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.5",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
type: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Process each operation
|
||||
for (const op of operations) {
|
||||
if (op.operation === "update") {
|
||||
if (op.type === "update") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
@@ -129,7 +129,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Update the map with the new element
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "add") {
|
||||
} else if (op.type === "add") {
|
||||
// Check if ID already exists
|
||||
if (cellMap.has(op.cell_id)) {
|
||||
errors.push({
|
||||
@@ -181,7 +181,7 @@ export function applyDiagramOperations(
|
||||
|
||||
// Add to map
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "delete") {
|
||||
} else if (op.type === "delete") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
|
||||
@@ -265,22 +265,14 @@ server.registerTool(
|
||||
"- 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" +
|
||||
"- 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.\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"}]}',
|
||||
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
|
||||
inputSchema: {
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
operation: z
|
||||
type: z
|
||||
.enum(["update", "add", "delete"])
|
||||
.describe(
|
||||
"Operation to perform: add, update, or delete",
|
||||
),
|
||||
.describe("Operation type"),
|
||||
cell_id: z.string().describe("The id of the mxCell"),
|
||||
new_xml: z
|
||||
.string()
|
||||
@@ -364,13 +356,13 @@ server.registerTool(
|
||||
)
|
||||
if (fixed) {
|
||||
log.info(
|
||||
`Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
||||
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
||||
)
|
||||
return { ...op, new_xml: fixed }
|
||||
}
|
||||
if (!valid && error) {
|
||||
log.warn(
|
||||
`Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`,
|
||||
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,8 +459,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Removed quotes around color values in style")
|
||||
}
|
||||
|
||||
// 10. Fix unescaped < and > in attribute values
|
||||
// < is required to be escaped, > is not strictly required but we escape for consistency
|
||||
// 10. Fix unescaped < in attribute values
|
||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||
let attrMatch
|
||||
let hasUnescapedLt = false
|
||||
@@ -472,10 +471,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
if (hasUnescapedLt) {
|
||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
||||
const escaped = value.replace(/</g, "<")
|
||||
return `="${escaped}"`
|
||||
})
|
||||
fixes.push("Escaped <> characters in attribute values")
|
||||
fixes.push("Escaped < characters in attribute values")
|
||||
}
|
||||
|
||||
// 11. Fix invalid hex character references
|
||||
@@ -904,30 +903,24 @@ export function validateAndFixXml(xml: string): {
|
||||
|
||||
/**
|
||||
* Check if mxCell XML output is complete (not truncated).
|
||||
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||
* @param xml - The XML string to check (can be undefined/null)
|
||||
* @returns true if XML appears complete, false if truncated or empty
|
||||
*/
|
||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||
const trimmed = xml?.trim() || ""
|
||||
let trimmed = xml?.trim() || ""
|
||||
if (!trimmed) return false
|
||||
|
||||
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||
// Strip wrapper tags if present
|
||||
let prev = ""
|
||||
while (prev !== trimmed) {
|
||||
prev = trimmed
|
||||
trimmed = trimmed
|
||||
.replace(/<\/mxParameter>\s*$/i, "")
|
||||
.replace(/<\/invoke>\s*$/i, "")
|
||||
.replace(/<\/antml:parameter>\s*$/i, "")
|
||||
.replace(/<\/antml:invoke>\s*$/i, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||
|
||||
// No valid ending found at all
|
||||
if (lastValidEnd === -1) return false
|
||||
|
||||
// Check what comes after the last valid ending
|
||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||
|
||||
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
/**
|
||||
* Development script for running Electron with Next.js
|
||||
* 1. Starts Next.js dev server
|
||||
* 2. Waits for it to be ready
|
||||
* 3. Compiles Electron TypeScript
|
||||
* 4. Launches Electron
|
||||
* 1. Reads preset configuration (if exists)
|
||||
* 2. Starts Next.js dev server with preset env vars
|
||||
* 3. Waits for it to be ready
|
||||
* 4. Compiles Electron TypeScript
|
||||
* 5. Launches Electron
|
||||
* 6. Watches for preset changes and restarts Next.js
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process"
|
||||
import { existsSync, readFileSync, watch } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
@@ -18,6 +22,64 @@ const rootDir = path.join(__dirname, "..")
|
||||
const NEXT_PORT = 6002
|
||||
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
|
||||
*/
|
||||
@@ -67,14 +129,25 @@ function runCommand(command, args, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Next.js dev server
|
||||
* Start Next.js dev server with preset environment
|
||||
*/
|
||||
function startNextServer() {
|
||||
function startNextServer(presetEnv) {
|
||||
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"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
env,
|
||||
})
|
||||
|
||||
nextProcess.on("error", (err) => {
|
||||
@@ -90,9 +163,12 @@ function startNextServer() {
|
||||
async function main() {
|
||||
console.log("🚀 Starting Electron development environment...\n")
|
||||
|
||||
// Start Next.js dev server
|
||||
// Load preset configuration
|
||||
const presetEnv = loadPresetConfig()
|
||||
|
||||
// Start Next.js dev server with preset env
|
||||
console.log("1. Starting Next.js development server...")
|
||||
const nextProcess = startNextServer()
|
||||
let nextProcess = startNextServer(presetEnv)
|
||||
|
||||
// Wait for Next.js to be ready
|
||||
try {
|
||||
@@ -127,14 +203,75 @@ 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) => {
|
||||
console.log(`\nElectron exited with code ${code}`)
|
||||
if (configWatcher) configWatcher.close()
|
||||
nextProcess.kill()
|
||||
process.exit(code || 0)
|
||||
})
|
||||
|
||||
electronProcess.on("error", (err) => {
|
||||
console.error("Electron error:", err)
|
||||
if (configWatcher) configWatcher.close()
|
||||
nextProcess.kill()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -142,6 +279,7 @@ async function main() {
|
||||
// Handle termination signals
|
||||
const cleanup = () => {
|
||||
console.log("\n🛑 Shutting down...")
|
||||
if (configWatcher) configWatcher.close()
|
||||
electronProcess.kill()
|
||||
nextProcess.kill()
|
||||
process.exit(0)
|
||||
|
||||
@@ -22,7 +22,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `XML parse error: ${parseError.textContent}`,
|
||||
},
|
||||
@@ -36,7 +36,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
@@ -51,11 +51,11 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
})
|
||||
|
||||
for (const op of operations) {
|
||||
if (op.operation === "update") {
|
||||
if (op.type === "update") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" not found`,
|
||||
})
|
||||
@@ -63,7 +63,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
}
|
||||
if (!op.new_xml) {
|
||||
errors.push({
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml is required for update operation",
|
||||
})
|
||||
@@ -76,7 +76,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml must contain an mxCell element",
|
||||
})
|
||||
@@ -85,7 +85,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cellId: op.cell_id,
|
||||
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)
|
||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "add") {
|
||||
} else if (op.type === "add") {
|
||||
if (cellMap.has(op.cell_id)) {
|
||||
errors.push({
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" already exists`,
|
||||
})
|
||||
@@ -105,7 +105,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
}
|
||||
if (!op.new_xml) {
|
||||
errors.push({
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml is required for add operation",
|
||||
})
|
||||
@@ -118,7 +118,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
message: "new_xml must contain an mxCell element",
|
||||
})
|
||||
@@ -127,7 +127,7 @@ function applyDiagramOperations(xmlContent, operations) {
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cellId: op.cell_id,
|
||||
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)
|
||||
root.appendChild(importedNode)
|
||||
cellMap.set(op.cell_id, importedNode)
|
||||
} else if (op.operation === "delete") {
|
||||
} else if (op.type === "delete") {
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
errors.push({
|
||||
operation: "delete",
|
||||
type: "delete",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" not found`,
|
||||
})
|
||||
@@ -201,7 +201,7 @@ function assert(condition, message) {
|
||||
test("Update operation changes cell value", () => {
|
||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cell_id: "2",
|
||||
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>',
|
||||
@@ -224,7 +224,7 @@ test("Update operation changes cell value", () => {
|
||||
test("Update operation fails for non-existent cell", () => {
|
||||
const { errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cell_id: "999",
|
||||
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", () => {
|
||||
const { errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cell_id: "2",
|
||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||
},
|
||||
@@ -254,7 +254,7 @@ test("Update operation fails on ID mismatch", () => {
|
||||
test("Add operation creates new cell", () => {
|
||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cell_id: "new1",
|
||||
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>',
|
||||
@@ -274,7 +274,7 @@ test("Add operation creates new cell", () => {
|
||||
test("Add operation fails for duplicate ID", () => {
|
||||
const { errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cell_id: "2",
|
||||
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", () => {
|
||||
const { errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cell_id: "new1",
|
||||
new_xml: '<mxCell id="WRONG" value="Test"/>',
|
||||
},
|
||||
@@ -303,7 +303,7 @@ test("Add operation fails on ID mismatch", () => {
|
||||
|
||||
test("Delete operation removes cell", () => {
|
||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||
{ operation: "delete", cell_id: "3" },
|
||||
{ type: "delete", cell_id: "3" },
|
||||
])
|
||||
assert(
|
||||
errors.length === 0,
|
||||
@@ -315,7 +315,7 @@ test("Delete operation removes cell", () => {
|
||||
|
||||
test("Delete operation fails for non-existent cell", () => {
|
||||
const { errors } = applyDiagramOperations(sampleXml, [
|
||||
{ operation: "delete", cell_id: "999" },
|
||||
{ type: "delete", cell_id: "999" },
|
||||
])
|
||||
assert(errors.length === 1, "Should have one error")
|
||||
assert(
|
||||
@@ -327,18 +327,18 @@ test("Delete operation fails for non-existent cell", () => {
|
||||
test("Multiple operations in sequence", () => {
|
||||
const { result, errors } = applyDiagramOperations(sampleXml, [
|
||||
{
|
||||
operation: "update",
|
||||
type: "update",
|
||||
cell_id: "2",
|
||||
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>',
|
||||
},
|
||||
{
|
||||
operation: "add",
|
||||
type: "add",
|
||||
cell_id: "new1",
|
||||
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>',
|
||||
},
|
||||
{ operation: "delete", cell_id: "3" },
|
||||
{ type: "delete", cell_id: "3" },
|
||||
])
|
||||
assert(
|
||||
errors.length === 0,
|
||||
@@ -354,14 +354,14 @@ test("Multiple operations in sequence", () => {
|
||||
|
||||
test("Invalid XML returns parse error", () => {
|
||||
const { errors } = applyDiagramOperations("<not valid xml", [
|
||||
{ operation: "delete", cell_id: "1" },
|
||||
{ type: "delete", cell_id: "1" },
|
||||
])
|
||||
assert(errors.length === 1, "Should have one error")
|
||||
})
|
||||
|
||||
test("Missing root element returns error", () => {
|
||||
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
|
||||
{ operation: "delete", cell_id: "1" },
|
||||
{ type: "delete", cell_id: "1" },
|
||||
])
|
||||
assert(errors.length === 1, "Should have one error")
|
||||
assert(
|
||||
|
||||
Reference in New Issue
Block a user