Compare commits

..

12 Commits

Author SHA1 Message Date
dayuan.jiang
6086c4177a fix: improve duplicate model validation UX in config dialog
- Add inline error display for duplicate model IDs
- Show red border on input when error exists
- Validate on blur with shake animation for edit errors
- Prevent saving empty model names
- Clear errors when user starts typing
- Simplify error styling (small red text, no heavy chips)
2025-12-22 22:27:49 +09:00
dayuan.jiang
33fd2a16e6 fix: improve duplicate model ID notifications
- Add toast notification when trying to add duplicate model
- Allow free typing when editing model ID, validate on blur
- Show warning toast instead of blocking input
2025-12-22 21:57:02 +09:00
dayuan.jiang
41c450516c fix: prevent duplicate model IDs within same provider
- Block adding model if ID already exists in provider
- Block editing model ID to match existing model in provider
2025-12-22 21:53:56 +09:00
dayuan.jiang
0e8783ccfb fix: UI/UX improvements for model configuration dialog
- Add gradient header styling with icon badge
- Change Configuration section icon from Key to Settings2
- Add duplicate model detection with warning banner and inline removal
- Filter out already-added models from suggestions dropdown
- Add type-to-confirm for deleting providers with 3+ models
- Enhance delete confirmation dialog with warning icon
- Improve model selector discoverability (show model name + chevron)
- Add truncation for long model names with title tooltip
- Remove AI provider settings from Settings dialog (now in Model Config)
- Extract ValidationButton into reusable component
2025-12-22 21:49:29 +09:00
dayuan.jiang
7cf6d7e7bd chore: remove unused code
- Remove unused setAccessCodeRequired state in chat-panel.tsx
- Remove unused getSelectedModel export in model-config.ts
2025-12-22 20:46:12 +09:00
dayuan.jiang
7ed7b29274 fix: complete bedrock support and UI/UX improvements
- Add bedrock to ALLOWED_CLIENT_PROVIDERS for client credentials
- Pass AWS credentials through full chain (headers → API → provider)
- Replace non-existent GPT-5 models with real ones (o1, o3-mini)
- Add accessibility: aria-labels, focus-visible rings, inline errors
- Add more AWS regions (Ohio, London, Paris, Mumbai, Seoul, São Paulo)
- Fix setTimeout cleanup with useRef on component unmount
- Fix TypeScript type consistency in getSelectedAIConfig fallback
2025-12-22 20:40:12 +09:00
dayuan.jiang
1be0cfa06c fix: reset validation button to Test after success 2025-12-22 20:13:57 +09:00
dayuan.jiang
1f6ef7ac90 fix: reset Test button after validation completes 2025-12-22 20:11:46 +09:00
dayuan.jiang
56ca9d3f48 feat: add AWS credentials support for Bedrock provider
- Add AWS Access Key ID, Secret Access Key, Region fields for Bedrock
- Show different credential fields based on provider type
- Update validation API to handle Bedrock with AWS credentials
- Add region selector with common AWS regions
2025-12-22 20:09:16 +09:00
dayuan.jiang
e089702949 refactor: revert shadcn component changes, apply hover fix at usage site 2025-12-22 20:02:58 +09:00
dayuan.jiang
89b0a96b95 feat: improve model config UI and move selector to chat input
- Move model selector from header to chat input (left of send button)
- Add per-model validation status (queued, running, valid, invalid)
- Filter model selector to only show verified models
- Add editable model IDs in config dialog
- Add custom model input field alongside suggested models dropdown
- Fix hover states on provider buttons and select triggers
- Update OpenAI suggested models with GPT-5 series
- Add alert-dialog component for delete confirmation
2025-12-22 20:00:45 +09:00
dayuan.jiang
1e916aa86e feat: add multi-provider model configuration
- Add model config dialog for managing multiple AI providers
- Support for OpenAI, Anthropic, Google, Azure, Bedrock, OpenRouter, DeepSeek, SiliconFlow, Ollama, and AI Gateway
- Add model selector dropdown in chat panel header
- Add API key validation endpoint
- Add custom model ID input with keyboard navigation
- Fix hover highlight in Command component
- Add suggested models for each provider including latest Claude 4.5 series
- Store configuration locally in browser
2025-12-22 17:58:05 +09:00
33 changed files with 1144 additions and 2275 deletions

View File

@@ -63,8 +63,6 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
# Push to AWS ECR for App Runner auto-deploy # Push to AWS ECR for App Runner auto-deploy
- name: Configure AWS credentials - name: Configure AWS credentials

View File

@@ -26,10 +26,6 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL} ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build Next.js application (standalone mode) # Build Next.js application (standalone mode)
RUN npm run build RUN npm run build

View File

@@ -117,9 +117,9 @@ export default function AboutCN() {
(TPS/TPM) (TPS/TPM)
</p> </p>
<p> <p>
使 Opus 4.5 {" "} 使 Claude {" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
Haiku 4.5 minimax-m2
</span> </span>
</p> </p>

View File

@@ -126,9 +126,9 @@ export default function AboutJA() {
</p> </p>
<p> <p>
Opus 4.5 {" "} Claude {" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
Haiku 4.5 minimax-m2
</span>{" "} </span>{" "}
</p> </p>

View File

@@ -129,9 +129,9 @@ export default function About() {
</p> </p>
<p> <p>
Due to the high usage, I have changed the Due to the high usage, I have changed the
model from Opus 4.5 to{" "} model from Claude to{" "}
<span className="font-semibold text-amber-700"> <span className="font-semibold text-amber-700">
Haiku 4.5 minimax-m2
</span> </span>
, which is more cost-effective. , which is more cost-effective.
</p> </p>

View File

@@ -14,11 +14,6 @@ import path from "path"
import { z } from "zod" import { z } from "zod"
import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers" import { getAIModel, supportsPromptCaching } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import { import {
getTelemetryConfig, getTelemetryConfig,
setTraceInput, setTraceInput,
@@ -167,13 +162,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, previousXml, sessionId } = await req.json() const { messages, xml, previousXml, sessionId } = await req.json()
// Get user IP for Langfuse tracking (hashed for privacy) // Get user IP for Langfuse tracking
const forwardedFor = req.headers.get("x-forwarded-for") const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous" const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
// Validate sessionId for Langfuse (must be string, max 200 chars) // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId = const validSessionId =
@@ -182,12 +173,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
: undefined : undefined
// Extract user input text for Langfuse trace // 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 lastMessage = messages[messages.length - 1]
const lastUserMessage = [...messages]
.reverse()
.find((m: any) => m.role === "user")
const userInputText = 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 // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
@@ -196,33 +184,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId, 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 === // === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages) const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) { if (!fileValidation.valid) {
@@ -276,10 +237,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle) const systemMessage = getSystemPrompt(modelId, minimalStyle)
// Extract file parts (images) from the last user message // Extract file parts (images) from the last message
const fileParts = 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 // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
@@ -288,7 +248,7 @@ ${userInputText}
"""` """`
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages) const modelMessages = convertToModelMessages(messages)
// DEBUG: Log incoming messages structure // DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length) console.log("[route.ts] Incoming messages count:", messages.length)
@@ -542,26 +502,12 @@ ${userInputText}
userId, userId,
}), }),
}), }),
onFinish: ({ text, totalUsage }) => { onFinish: ({ text, usage }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans // Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
setTraceOutput(text) setTraceOutput(text, {
promptTokens: usage?.inputTokens,
// Record token usage for server-side quota tracking (if enabled) completionTokens: usage?.outputTokens,
// 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)
}
}, },
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
@@ -731,9 +677,19 @@ Call this tool to get shape names and usage syntax for a specific library.`,
messageMetadata: ({ part }) => { messageMetadata: ({ part }) => {
if (part.type === "finish") { if (part.type === "finish") {
const usage = (part as any).totalUsage 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.cachedInputTokens ?? 0)
return { return {
totalTokens: usage?.totalTokens ?? 0, inputTokens: totalInputTokens,
outputTokens: usage.outputTokens ?? 0,
finishReason: (part as any).finishReason, finishReason: (part as any).finishReason,
} }
} }

View File

@@ -27,18 +27,9 @@ export async function POST(req: Request) {
const { messageId, feedback, sessionId } = data const { messageId, feedback, sessionId } = data
// Skip logging if no sessionId - prevents attaching to wrong user's trace // Get user IP for tracking
if (!sessionId) {
return Response.json({ success: true, logged: false })
}
// Get user IP for tracking (hashed for privacy)
const forwardedFor = req.headers.get("x-forwarded-for") const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous" const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
try { try {
// Find the most recent chat trace for this session to attach the score to // Find the most recent chat trace for this session to attach the score to

View File

@@ -27,11 +27,6 @@ export async function POST(req: Request) {
const { filename, format, sessionId } = data 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 { try {
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()

View File

@@ -11,66 +11,6 @@ import { createOllama } from "ollama-ai-provider-v2"
export const runtime = "nodejs" export const runtime = "nodejs"
/**
* SECURITY: Check if URL points to private/internal network (SSRF protection)
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// Block localhost
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
// 10.0.0.0/8
if (a === 10) return true
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true
// 192.168.0.0/16
if (a === 192 && b === 168) return true
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true
// 127.0.0.0/8 (loopback)
if (a === 127) return true
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
} catch {
// Invalid URL - block it
return true
}
}
interface ValidateRequest { interface ValidateRequest {
provider: string provider: string
apiKey: string apiKey: string
@@ -102,14 +42,6 @@ export async function POST(req: Request) {
) )
} }
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider // Validate credentials based on provider
if (provider === "bedrock") { if (provider === "bedrock") {
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) { if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {

View File

@@ -1,24 +1,24 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
import { getAssetUrl } from "@/lib/base-path"
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "Next AI Draw.io", name: "Next AI Draw.io",
short_name: "AIDraw.io", short_name: "AIDraw.io",
description: description:
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
start_url: getAssetUrl("/"), start_url: "/",
display: "standalone", display: "standalone",
background_color: "#f9fafb", background_color: "#f9fafb",
theme_color: "#171d26", theme_color: "#171d26",
icons: [ icons: [
{ {
src: getAssetUrl("/favicon-192x192.png"), src: "/favicon-192x192.png",
sizes: "192x192", sizes: "192x192",
type: "image/png", type: "image/png",
purpose: "any", purpose: "any",
}, },
{ {
src: getAssetUrl("/favicon-512x512.png"), src: "/favicon-512x512.png",
sizes: "512x512", sizes: "512x512",
type: "image/png", type: "image/png",
purpose: "any", purpose: "any",

View File

@@ -9,7 +9,6 @@ import {
Zap, Zap,
} from "lucide-react" } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps { interface ExampleCardProps {
icon: React.ReactNode icon: React.ReactNode
@@ -80,7 +79,7 @@ export default function ExamplePanel({
setInput("Replicate this flowchart.") setInput("Replicate this flowchart.")
try { try {
const response = await fetch(getAssetUrl("/example.png")) const response = await fetch("/example.png")
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" }) const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file]) setFiles([file])
@@ -93,7 +92,7 @@ export default function ExamplePanel({
setInput("Replicate this in aws style") setInput("Replicate this in aws style")
try { try {
const response = await fetch(getAssetUrl("/architecture.png")) const response = await fetch("/architecture.png")
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "architecture.png", { const file = new File([blob], "architecture.png", {
type: "image/png", type: "image/png",
@@ -108,7 +107,7 @@ export default function ExamplePanel({
setInput("Summarize this paper as a diagram") setInput("Summarize this paper as a diagram")
try { try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt")) const response = await fetch("/chain-of-thought.txt")
const blob = await response.blob() const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", { const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain", type: "text/plain",

View File

@@ -27,11 +27,9 @@ import {
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { getApiEndpoint } from "@/lib/base-path"
import { import {
applyDiagramOperations, applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
extractCompleteMxCells,
isMxCellXmlComplete, isMxCellXmlComplete,
replaceNodes, replaceNodes,
validateAndFixXml, validateAndFixXml,
@@ -293,7 +291,7 @@ export function ChatMessageDisplay({
setFeedback((prev) => ({ ...prev, [messageId]: value })) setFeedback((prev) => ({ ...prev, [messageId]: value }))
try { try {
await fetch(getApiEndpoint("/api/log-feedback"), { await fetch("/api/log-feedback", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -316,28 +314,12 @@ export function ChatMessageDisplay({
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string, showToast = false) => { (xml: string, showToast = false) => {
let currentXml = xml || "" const 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 convertedXml = convertToLegalXml(currentXml) const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes // Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser() const parser = new DOMParser()
// Wrap in root element for parsing multiple mxCell elements const testDoc = parser.parseFromString(convertedXml, "text/xml")
const testDoc = parser.parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
const parseError = testDoc.querySelector("parsererror") const parseError = testDoc.querySelector("parsererror")
if (parseError) { if (parseError) {
@@ -364,22 +346,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>` `<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 replacedXML = replaceNodes(baseXML, convertedXml)
const xmlProcessTime = performance.now() - startTime // Validate and auto-fix the XML
// 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
const validation = validateAndFixXml(replacedXML) const validation = validateAndFixXml(replacedXML)
if (validation.valid) { if (validation.valid) {
previousXML.current = convertedXml previousXML.current = convertedXml
@@ -392,19 +359,18 @@ export function ChatMessageDisplay({
) )
} }
// Skip validation in loadDiagram since we already validated above // Skip validation in loadDiagram since we already validated above
const loadStartTime = performance.now()
onDisplayChart(xmlToLoad, true) onDisplayChart(xmlToLoad, true)
console.log(
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
)
} else { } else {
console.error( console.error(
"[ChatMessageDisplay] XML validation failed:", "[ChatMessageDisplay] XML validation failed:",
validation.error, validation.error,
) )
toast.error( // Only show toast if this is the final XML (not during streaming)
"Diagram validation failed. Please try regenerating.", if (showToast) {
) toast.error(
"Diagram validation failed. Please try regenerating.",
)
}
} }
} catch (error) { } catch (error) {
console.error( console.error(
@@ -636,10 +602,17 @@ export function ChatMessageDisplay({
} }
}) })
// NOTE: Don't cleanup debounce timeouts here! // Cleanup: clear any pending debounce timeout on unmount
// The cleanup runs on every re-render (when messages changes), return () => {
// which would cancel the timeout before it fires. if (debounceTimeoutRef.current) {
// Let the timeouts complete naturally - they're harmless if component unmounts. clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
}
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
}
}, [messages, handleDisplayChart, chartXML]) }, [messages, handleDisplayChart, chartXML])
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {

View File

@@ -21,21 +21,16 @@ import { ChatInput } from "@/components/chat-input"
import { ModelConfigDialog } from "@/components/model-config-dialog" import { ModelConfigDialog } from "@/components/model-config-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal" import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SettingsDialog } from "@/components/settings-dialog" import { SettingsDialog } from "@/components/settings-dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import LanguageToggle from "./language-toggle"
// localStorage keys for persistence // localStorage keys for persistence
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages" const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
@@ -76,7 +71,6 @@ interface ChatPanelProps {
const TOOL_ERROR_STATE = "output-error" as const const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1 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. * Check if auto-resubmit should happen based on tool errors.
@@ -174,7 +168,7 @@ export default function ChatPanel({
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
fetch(getApiEndpoint("/api/config")) fetch("/api/config")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setDailyRequestLimit(data.dailyRequestLimit || 0) setDailyRequestLimit(data.dailyRequestLimit || 0)
@@ -217,8 +211,6 @@ export default function ChatPanel({
// Ref to track consecutive auto-retry count (reset on user action) // Ref to track consecutive auto-retry count (reset on user action)
const autoRetryCountRef = useRef(0) const autoRetryCountRef = useRef(0)
// Ref to track continuation retry count (for truncation handling)
const continuationRetryCountRef = useRef(0)
// Ref to accumulate partial XML when output is truncated due to maxOutputTokens // Ref to accumulate partial XML when output is truncated due to maxOutputTokens
// When partialXmlRef.current.length > 0, we're in continuation mode // When partialXmlRef.current.length > 0, we're in continuation mode
@@ -247,7 +239,7 @@ export default function ChatPanel({
setMessages, setMessages,
} = useChat({ } = useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"), api: "/api/chat",
}), }),
async onToolCall({ toolCall }) { async onToolCall({ toolCall }) {
if (DEBUG) { if (DEBUG) {
@@ -556,23 +548,6 @@ Continue from EXACTLY where you stopped.`,
} }
}, },
onError: (error) => { onError: (error) => {
// Handle server-side quota limit (429 response)
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast(dailyTokenLimit)
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 // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) { if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error) console.error("Chat error:", error)
@@ -649,6 +624,22 @@ Continue from EXACTLY where you stopped.`,
// DEBUG: Log finish reason to diagnose truncation // DEBUG: Log finish reason to diagnose truncation
console.log("[onFinish] finishReason:", metadata?.finishReason) 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 }) => { sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0 const isInContinuationMode = partialXmlRef.current.length > 0
@@ -660,25 +651,15 @@ Continue from EXACTLY where you stopped.`,
if (!shouldRetry) { if (!shouldRetry) {
// No error, reset retry count and clear state // No error, reset retry count and clear state
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
return false 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 (isInContinuationMode) {
if ( // Don't count against retry limit for continuation
continuationRetryCountRef.current >= // Quota checks still apply below
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++
} else { } else {
// Regular error: check retry count limit // Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
@@ -693,6 +674,23 @@ Continue from EXACTLY where you stopped.`,
autoRetryCountRef.current++ 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 return true
}, },
}) })
@@ -909,6 +907,9 @@ Continue from EXACTLY where you stopped.`,
xmlSnapshotsRef.current.set(messageIndex, chartXml) xmlSnapshotsRef.current.set(messageIndex, chartXml)
saveXmlSnapshots() saveXmlSnapshots()
// Check all quota limits
if (!checkAllQuotaLimits()) return
sendChatMessage(parts, chartXml, previousXml, sessionId) sendChatMessage(parts, chartXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage // Token count is tracked in onFinish with actual server usage
@@ -986,7 +987,30 @@ Continue from EXACTLY where you stopped.`,
saveXmlSnapshots() 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 = ( const sendChatMessage = (
parts: any, parts: any,
xml: string, xml: string,
@@ -995,7 +1019,6 @@ Continue from EXACTLY where you stopped.`,
) => { ) => {
// Reset all retry/continuation state on user-initiated message // Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
const config = getSelectedAIConfig() const config = getSelectedAIConfig()
@@ -1036,6 +1059,7 @@ Continue from EXACTLY where you stopped.`,
}, },
}, },
) )
quotaManager.incrementRequestCount()
} }
// Process files and append content to user text (handles PDF, text, and optionally images) // Process files and append content to user text (handles PDF, text, and optionally images)
@@ -1123,8 +1147,13 @@ Continue from EXACTLY where you stopped.`,
setMessages(newMessages) setMessages(newMessages)
}) })
// Check all quota limits
if (!checkAllQuotaLimits()) return
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
sendChatMessage(userParts, savedXml, previousXml, sessionId) sendChatMessage(userParts, savedXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
} }
const handleEditMessage = async (messageIndex: number, newText: string) => { const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -1166,8 +1195,12 @@ Continue from EXACTLY where you stopped.`,
setMessages(newMessages) setMessages(newMessages)
}) })
// Check all quota limits
if (!checkAllQuotaLimits()) return
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
sendChatMessage(newParts, savedXml, previousXml, sessionId) sendChatMessage(newParts, savedXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
} }
// Collapsed view (desktop only) // Collapsed view (desktop only)
@@ -1231,18 +1264,32 @@ Continue from EXACTLY where you stopped.`,
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
{!isMobile && {!isMobile && (
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === <Link
"true" && ( href="/about"
<Link target="_blank"
href="/about" rel="noopener noreferrer"
target="_blank" className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
rel="noopener noreferrer" >
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2" About
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
variant="ghost"
size="icon"
className="h-6 w-6 text-amber-500 hover:text-amber-600"
> >
About <AlertTriangle className="h-4 w-4" />
</Link> </ButtonWithTooltip>
)} </Link>
)}
</div> </div>
<div className="flex items-center gap-1 justify-end overflow-visible"> <div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip <ButtonWithTooltip
@@ -1257,23 +1304,16 @@ Continue from EXACTLY where you stopped.`,
/> />
</ButtonWithTooltip> </ButtonWithTooltip>
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />
<a
<Tooltip> href="https://github.com/DayuanJiang/next-ai-draw-io"
<TooltipTrigger asChild> target="_blank"
<a rel="noopener noreferrer"
href="https://github.com/DayuanJiang/next-ai-draw-io" className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
target="_blank" >
rel="noopener noreferrer" <FaGithub
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
> />
<FaGithub </a>
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
</TooltipTrigger>
<TooltipContent>{dict.nav.github}</TooltipContent>
</Tooltip>
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.settings} tooltipContent={dict.nav.settings}
variant="ghost" variant="ghost"
@@ -1286,6 +1326,7 @@ Continue from EXACTLY where you stopped.`,
/> />
</ButtonWithTooltip> </ButtonWithTooltip>
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
<LanguageToggle />
{!isMobile && ( {!isMobile && (
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent={dict.nav.hidePanel} tooltipContent={dict.nav.hidePanel}

View File

@@ -0,0 +1,108 @@
"use client"
import { Globe } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useRef, useState } from "react"
import { i18n, type Locale } from "@/lib/i18n/config"
const LABELS: Record<string, string> = {
en: "EN",
zh: "中文",
ja: "日本語",
}
function LanguageToggleInner({ className = "" }: { className?: string }) {
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [open, setOpen] = useState(false)
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale))
setValue(first as Locale)
else setValue(i18n.defaultLocale)
}, [pathname])
useEffect(() => {
function onDoc(e: MouseEvent) {
if (!ref.current) return
if (!ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener("mousedown", onDoc)
return () => document.removeEventListener("mousedown", onDoc)
}, [open])
const changeLocale = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
setOpen(false)
router.push(newPath + searchStr)
}
return (
<div className={`relative inline-flex ${className}`} ref={ref}>
<button
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((s) => !s)}
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
<div className="grid gap-0 divide-y divide-border/30">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => changeLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
>
<span className="flex-1">
{LABELS[loc] ?? loc}
</span>
{value === loc && (
<span className="text-xs opacity-70">
</span>
)}
</button>
))}
</div>
</div>
)}
</div>
)
}
export default function LanguageToggle({
className = "",
}: {
className?: string
}) {
return (
<Suspense
fallback={
<button
className="p-2 rounded-full text-muted-foreground opacity-50"
disabled
>
<Globe className="w-5 h-5" />
</button>
}
>
<LanguageToggleInner className={className} />
</Suspense>
)
}

View File

@@ -52,7 +52,6 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config" import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -108,12 +107,10 @@ function ValidationButton({
status, status,
onClick, onClick,
disabled, disabled,
dict,
}: { }: {
status: ValidationStatus status: ValidationStatus
onClick: () => void onClick: () => void
disabled: boolean disabled: boolean
dict: ReturnType<typeof useDictionary>
}) { }) {
return ( return (
<Button <Button
@@ -132,10 +129,10 @@ function ValidationButton({
) : status === "success" ? ( ) : status === "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5" /> <Check className="h-4 w-4 mr-1.5" />
{dict.modelConfig.verified} Verified
</> </>
) : ( ) : (
dict.modelConfig.test "Test"
)} )}
</Button> </Button>
) )
@@ -409,7 +406,7 @@ export function ModelConfigDialog({
<div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20"> <div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20">
<div className="px-4 py-3 border-b"> <div className="px-4 py-3 border-b">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{dict.modelConfig.providers} Providers
</span> </span>
</div> </div>
@@ -421,7 +418,7 @@ export function ModelConfigDialog({
<Plus className="h-5 w-5 text-muted-foreground" /> <Plus className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{dict.modelConfig.addProviderHint} Add a provider to get started
</p> </p>
</div> </div>
) : ( ) : (
@@ -487,11 +484,7 @@ export function ModelConfigDialog({
> >
<SelectTrigger className="h-9 bg-background hover:bg-accent"> <SelectTrigger className="h-9 bg-background hover:bg-accent">
<Plus className="h-4 w-4 mr-2 text-muted-foreground" /> <Plus className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue <SelectValue placeholder="Add Provider" />
placeholder={
dict.modelConfig.addProvider
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableProviders.map((p) => ( {availableProviders.map((p) => (
@@ -559,27 +552,15 @@ export function ModelConfigDialog({
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{selectedProvider.models {selectedProvider.models
.length === 0 .length === 0
? dict.modelConfig ? "No models configured"
.noModelsConfigured : `${selectedProvider.models.length} model${selectedProvider.models.length > 1 ? "s" : ""} configured`}
: formatMessage(
dict.modelConfig
.modelsConfiguredCount,
{
count: selectedProvider
.models
.length,
},
)}
</p> </p>
</div> </div>
{selectedProvider.validated && ( {selectedProvider.validated && (
<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"> <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" /> <Check className="h-3.5 w-3.5" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{ Verified
dict.modelConfig
.verified
}
</span> </span>
</div> </div>
)} )}
@@ -589,12 +570,7 @@ export function ModelConfigDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Settings2 className="h-4 w-4" /> <Settings2 className="h-4 w-4" />
<span> <span>Configuration</span>
{
dict.modelConfig
.configuration
}
</span>
</div> </div>
<div className="rounded-xl border bg-card p-4 space-y-4"> <div className="rounded-xl border bg-card p-4 space-y-4">
@@ -605,10 +581,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Tag className="h-3.5 w-3.5 text-muted-foreground" /> <Tag className="h-3.5 w-3.5 text-muted-foreground" />
{ Display Name
dict.modelConfig
.displayName
}
</Label> </Label>
<Input <Input
id="provider-name" id="provider-name"
@@ -643,11 +616,8 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ AWS Access Key
dict ID
.modelConfig
.awsAccessKeyId
}
</Label> </Label>
<Input <Input
id="aws-access-key-id" id="aws-access-key-id"
@@ -679,11 +649,8 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ AWS Secret
dict Access Key
.modelConfig
.awsSecretAccessKey
}
</Label> </Label>
<div className="relative"> <div className="relative">
<Input <Input
@@ -707,11 +674,7 @@ export function ModelConfigDialog({
.value, .value,
) )
} }
placeholder={ placeholder="Enter your secret access key"
dict
.modelConfig
.enterSecretKey
}
className="h-9 pr-10 font-mono text-xs" className="h-9 pr-10 font-mono text-xs"
/> />
<button <button
@@ -744,11 +707,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{ AWS Region
dict
.modelConfig
.awsRegion
}
</Label> </Label>
<Select <Select
value={ value={
@@ -765,13 +724,7 @@ export function ModelConfigDialog({
} }
> >
<SelectTrigger className="h-9 font-mono text-xs hover:bg-accent"> <SelectTrigger className="h-9 font-mono text-xs hover:bg-accent">
<SelectValue <SelectValue placeholder="Select region" />
placeholder={
dict
.modelConfig
.selectRegion
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-64"> <SelectContent className="max-h-64">
<SelectItem value="us-east-1"> <SelectItem value="us-east-1">
@@ -866,16 +819,10 @@ export function ModelConfigDialog({
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5" /> <Check className="h-4 w-4 mr-1.5" />
{ Verified
dict
.modelConfig
.verified
}
</> </>
) : ( ) : (
dict "Test"
.modelConfig
.test
)} )}
</Button> </Button>
{validationStatus === {validationStatus ===
@@ -899,11 +846,7 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ API Key
dict
.modelConfig
.apiKey
}
</Label> </Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
@@ -927,11 +870,7 @@ export function ModelConfigDialog({
.value, .value,
) )
} }
placeholder={ placeholder="Enter your API key"
dict
.modelConfig
.enterApiKey
}
className="h-9 pr-10 font-mono text-xs" className="h-9 pr-10 font-mono text-xs"
/> />
<button <button
@@ -985,16 +924,10 @@ export function ModelConfigDialog({
"success" ? ( "success" ? (
<> <>
<Check className="h-4 w-4 mr-1.5" /> <Check className="h-4 w-4 mr-1.5" />
{ Verified
dict
.modelConfig
.verified
}
</> </>
) : ( ) : (
dict "Test"
.modelConfig
.test
)} )}
</Button> </Button>
</div> </div>
@@ -1017,17 +950,9 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{ Base URL
dict
.modelConfig
.baseUrl
}
<span className="text-muted-foreground font-normal"> <span className="text-muted-foreground font-normal">
{ (optional)
dict
.modelConfig
.optional
}
</span> </span>
</Label> </Label>
<Input <Input
@@ -1049,9 +974,7 @@ export function ModelConfigDialog({
.provider .provider
] ]
.defaultBaseUrl || .defaultBaseUrl ||
dict "Custom endpoint URL"
.modelConfig
.customEndpoint
} }
className="h-9 font-mono text-xs" className="h-9 font-mono text-xs"
/> />
@@ -1066,20 +989,12 @@ export function ModelConfigDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground"> <div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
<span> <span>Models</span>
{
dict.modelConfig
.models
}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<Input <Input
placeholder={ placeholder="Custom model ID..."
dict.modelConfig
.customModelId
}
value={ value={
customModelInput customModelInput
} }
@@ -1173,12 +1088,8 @@ export function ModelConfigDialog({
<span className="text-xs"> <span className="text-xs">
{availableSuggestions.length === {availableSuggestions.length ===
0 0
? dict ? "All added"
.modelConfig : "Suggested"}
.allAdded
: dict
.modelConfig
.suggested}
</span> </span>
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
@@ -1213,10 +1124,7 @@ export function ModelConfigDialog({
<Sparkles className="h-5 w-5 text-muted-foreground" /> <Sparkles className="h-5 w-5 text-muted-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{ No models configured
dict.modelConfig
.noModelsConfigured
}
</p> </p>
</div> </div>
) : ( ) : (
@@ -1383,9 +1291,7 @@ export function ModelConfigDialog({
!newModelId !newModelId
) { ) {
showError( showError(
dict "Model ID cannot be empty",
.modelConfig
.modelIdEmpty,
) )
return return
} }
@@ -1413,9 +1319,7 @@ export function ModelConfigDialog({
) )
) { ) {
showError( showError(
dict "This model ID already exists",
.modelConfig
.modelIdExists,
) )
return return
} }
@@ -1479,10 +1383,7 @@ export function ModelConfigDialog({
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10" className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
{ Delete Provider
dict.modelConfig
.deleteProvider
}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1494,10 +1395,11 @@ export function ModelConfigDialog({
<Server className="h-8 w-8 text-primary/60" /> <Server className="h-8 w-8 text-primary/60" />
</div> </div>
<h3 className="font-semibold mb-1"> <h3 className="font-semibold mb-1">
{dict.modelConfig.configureProviders} Configure AI Providers
</h3> </h3>
<p className="text-sm text-muted-foreground max-w-xs"> <p className="text-sm text-muted-foreground max-w-xs">
{dict.modelConfig.selectProviderHint} Select a provider from the list or add a new
one to configure API keys and models
</p> </p>
</div> </div>
)} )}
@@ -1508,7 +1410,7 @@ export function ModelConfigDialog({
<div className="px-6 py-3 border-t bg-muted/20"> <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"> <p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
<Key className="h-3 w-3" /> <Key className="h-3 w-3" />
{dict.modelConfig.apiKeyStored} API keys are stored locally in your browser
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
@@ -1527,16 +1429,19 @@ export function ModelConfigDialog({
<AlertCircle className="h-6 w-6 text-destructive" /> <AlertCircle className="h-6 w-6 text-destructive" />
</div> </div>
<AlertDialogTitle className="text-center"> <AlertDialogTitle className="text-center">
{dict.modelConfig.deleteProvider} Delete Provider
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-center"> <AlertDialogDescription className="text-center">
{formatMessage(dict.modelConfig.deleteConfirmDesc, { Are you sure you want to delete{" "}
name: selectedProvider <span className="font-medium text-foreground">
{selectedProvider
? selectedProvider.name || ? selectedProvider.name ||
PROVIDER_INFO[selectedProvider.provider] PROVIDER_INFO[selectedProvider.provider]
.label .label
: "this provider", : "this provider"}
})} </span>
? This will remove all configured models and cannot
be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{selectedProvider && {selectedProvider &&
@@ -1546,16 +1451,11 @@ export function ModelConfigDialog({
htmlFor="delete-confirm" htmlFor="delete-confirm"
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
> >
{formatMessage( Type &quot;
dict.modelConfig.typeToConfirm, {selectedProvider.name ||
{ PROVIDER_INFO[selectedProvider.provider]
name: .label}
selectedProvider.name || &quot; to confirm
PROVIDER_INFO[
selectedProvider.provider
].label,
},
)}
</Label> </Label>
<Input <Input
id="delete-confirm" id="delete-confirm"
@@ -1563,17 +1463,13 @@ export function ModelConfigDialog({
onChange={(e) => onChange={(e) =>
setDeleteConfirmText(e.target.value) setDeleteConfirmText(e.target.value)
} }
placeholder={ placeholder="Type provider name..."
dict.modelConfig.typeProviderName
}
className="h-9" className="h-9"
/> />
</div> </div>
)} )}
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
{dict.modelConfig.cancel}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDeleteProvider} onClick={handleDeleteProvider}
disabled={ disabled={
@@ -1586,7 +1482,7 @@ export function ModelConfigDialog({
} }
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
> >
{dict.modelConfig.delete} Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@@ -16,7 +16,6 @@ import {
ModelSelectorTrigger, ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector" } from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import type { FlattenedModel } from "@/lib/types/model-config" import type { FlattenedModel } from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -68,7 +67,6 @@ export function ModelSelector({
onConfigure, onConfigure,
disabled = false, disabled = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const dict = useDictionary()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Only show validated models in the selector // Only show validated models in the selector
const validatedModels = useMemo( const validatedModels = useMemo(
@@ -98,8 +96,8 @@ export function ModelSelector({
} }
const tooltipContent = selectedModel const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}` ? `${selectedModel.modelId} (click to change)`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}` : "Using server default model (click to change)"
return ( return (
<ModelSelectorRoot open={open} onOpenChange={setOpen}> <ModelSelectorRoot open={open} onOpenChange={setOpen}>
@@ -113,26 +111,22 @@ export function ModelSelector({
> >
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="text-xs truncate"> <span className="text-xs truncate">
{selectedModel {selectedModel ? selectedModel.modelId : "Default"}
? selectedModel.modelId
: dict.modelConfig.default}
</span> </span>
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip> </ButtonWithTooltip>
</ModelSelectorTrigger> </ModelSelectorTrigger>
<ModelSelectorContent title={dict.modelConfig.selectModel}> <ModelSelectorContent title="Select Model">
<ModelSelectorInput <ModelSelectorInput placeholder="Search models..." />
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList> <ModelSelectorList>
<ModelSelectorEmpty> <ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0 {validatedModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels ? "No verified models. Test your models first."
: dict.modelConfig.noModelsFound} : "No models found."}
</ModelSelectorEmpty> </ModelSelectorEmpty>
{/* Server Default Option */} {/* Server Default Option */}
<ModelSelectorGroup heading={dict.modelConfig.default}> <ModelSelectorGroup heading="Default">
<ModelSelectorItem <ModelSelectorItem
value="__server_default__" value="__server_default__"
onSelect={handleSelect} onSelect={handleSelect}
@@ -151,7 +145,7 @@ export function ModelSelector({
/> />
<Server className="mr-2 h-4 w-4 text-muted-foreground" /> <Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName> <ModelSelectorName>
{dict.modelConfig.serverDefault} Server Default
</ModelSelectorName> </ModelSelectorName>
</ModelSelectorItem> </ModelSelectorItem>
</ModelSelectorGroup> </ModelSelectorGroup>
@@ -207,13 +201,13 @@ export function ModelSelector({
> >
<Settings2 className="mr-2 h-4 w-4" /> <Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName> <ModelSelectorName>
{dict.modelConfig.configureModels} Configure Models...
</ModelSelectorName> </ModelSelectorName>
</ModelSelectorItem> </ModelSelectorItem>
</ModelSelectorGroup> </ModelSelectorGroup>
{/* Info text */} {/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t"> <div className="px-3 py-2 text-xs text-muted-foreground border-t">
{dict.modelConfig.onlyVerifiedShown} Only verified models are shown
</div> </div>
</ModelSelectorList> </ModelSelectorList>
</ModelSelectorContent> </ModelSelectorContent>

View File

@@ -1,8 +1,7 @@
"use client" "use client"
import { Moon, Sun } from "lucide-react" import { Moon, Sun } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -13,23 +12,8 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { i18n, type Locale } from "@/lib/i18n/config"
const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
}
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
@@ -52,7 +36,7 @@ function getStoredAccessCodeRequired(): boolean | null {
return stored === "true" return stored === "true"
} }
function SettingsContent({ export function SettingsDialog({
open, open,
onOpenChange, onOpenChange,
onCloseProtectionChange, onCloseProtectionChange,
@@ -62,9 +46,6 @@ function SettingsContent({
onToggleDarkMode, onToggleDarkMode,
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter()
const pathname = usePathname() || "/"
const search = useSearchParams()
const [accessCode, setAccessCode] = useState("") const [accessCode, setAccessCode] = useState("")
const [closeProtection, setCloseProtection] = useState(true) const [closeProtection, setCloseProtection] = useState(true)
const [isVerifying, setIsVerifying] = useState(false) const [isVerifying, setIsVerifying] = useState(false)
@@ -72,13 +53,12 @@ function SettingsContent({
const [accessCodeRequired, setAccessCodeRequired] = useState( const [accessCodeRequired, setAccessCodeRequired] = useState(
() => getStoredAccessCodeRequired() ?? false, () => getStoredAccessCodeRequired() ?? false,
) )
const [currentLang, setCurrentLang] = useState("en")
useEffect(() => { useEffect(() => {
// Only fetch if not cached in localStorage // Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config")) fetch("/api/config")
.then((res) => { .then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() return res.json()
@@ -97,17 +77,6 @@ function SettingsContent({
}) })
}, []) }, [])
// Detect current language from pathname
useEffect(() => {
const seg = pathname.split("/").filter(Boolean)
const first = seg[0]
if (first && i18n.locales.includes(first as Locale)) {
setCurrentLang(first)
} else {
setCurrentLang(i18n.defaultLocale)
}
}, [pathname])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = const storedCode =
@@ -124,18 +93,6 @@ function SettingsContent({
} }
}, [open]) }, [open])
const changeLanguage = (lang: string) => {
const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang
} else {
parts.splice(1, 0, lang)
}
const newPath = parts.join("/") || "/"
const searchStr = search?.toString() ? `?${search.toString()}` : ""
router.push(newPath + searchStr)
}
const handleSave = async () => { const handleSave = async () => {
if (!accessCodeRequired) return if (!accessCodeRequired) return
@@ -143,15 +100,12 @@ function SettingsContent({
setIsVerifying(true) setIsVerifying(true)
try { try {
const response = await fetch( const response = await fetch("/api/verify-access-code", {
getApiEndpoint("/api/verify-access-code"), method: "POST",
{ headers: {
method: "POST", "x-access-code": accessCode.trim(),
headers: {
"x-access-code": accessCode.trim(),
},
}, },
) })
const data = await response.json() const data = await response.json()
@@ -177,166 +131,128 @@ function SettingsContent({
} }
return ( return (
<DialogContent className="sm:max-w-md"> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogHeader> <DialogContent className="sm:max-w-md">
<DialogTitle>{dict.settings.title}</DialogTitle> <DialogHeader>
<DialogDescription> <DialogTitle>{dict.settings.title}</DialogTitle>
{dict.settings.description} <DialogDescription>
</DialogDescription> {dict.settings.description}
</DialogHeader> </DialogDescription>
<div className="space-y-4 py-2"> </DialogHeader>
{accessCodeRequired && ( <div className="space-y-4 py-2">
<div className="space-y-2"> {accessCodeRequired && (
<Label htmlFor="access-code"> <div className="space-y-2">
{dict.settings.accessCode} <Label htmlFor="access-code">
</Label> {dict.settings.accessCode}
<div className="flex gap-2"> </Label>
<Input <div className="flex gap-2">
id="access-code" <Input
type="password" id="access-code"
value={accessCode} type="password"
onChange={(e) => setAccessCode(e.target.value)} value={accessCode}
onKeyDown={handleKeyDown} onChange={(e) =>
placeholder={ setAccessCode(e.target.value)
dict.settings.accessCodePlaceholder }
} onKeyDown={handleKeyDown}
autoComplete="off" placeholder={
/> dict.settings.accessCodePlaceholder
<Button }
onClick={handleSave} autoComplete="off"
disabled={isVerifying || !accessCode.trim()} />
> <Button
{isVerifying ? "..." : dict.common.save} onClick={handleSave}
</Button> disabled={isVerifying || !accessCode.trim()}
</div> >
<p className="text-[0.8rem] text-muted-foreground"> {isVerifying ? "..." : dict.common.save}
{dict.settings.accessCodeDescription} </Button>
</p> </div>
{error && ( <p className="text-[0.8rem] text-muted-foreground">
<p className="text-[0.8rem] text-destructive"> {dict.settings.accessCodeDescription}
{error}
</p> </p>
)} {error && (
</div> <p className="text-[0.8rem] text-destructive">
)} {error}
</p>
<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>
<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}
>
{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.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{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>
<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>
</DialogContent>
)
}
export function SettingsDialog(props: SettingsDialogProps) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<Suspense
fallback={
<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> </div>
</DialogContent> )}
} <div className="flex items-center justify-between">
> <div className="space-y-0.5">
<SettingsContent {...props} /> <Label htmlFor="theme-toggle">
</Suspense> {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}
>
{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.minimal
: dict.settings.sketch}
</p>
</div>
<Button
id="drawio-ui"
variant="outline"
size="sm"
onClick={onToggleDrawioUi}
>
{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>
<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>
</DialogContent>
</Dialog> </Dialog>
) )
} }

View File

@@ -5,7 +5,6 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel" import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import { extractDiagramXML, validateAndFixXml } from "../lib/utils" import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
@@ -330,7 +329,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
sessionId?: string, sessionId?: string,
) => { ) => {
try { try {
await fetch(getApiEndpoint("/api/log-save"), { await fetch("/api/log-save", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }), body: JSON.stringify({ filename, format, sessionId }),

View File

@@ -7,11 +7,6 @@ services:
context: . context: .
args: args:
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080 - NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
environment:
# For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

View File

@@ -68,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SILICONFLOW_API_KEY=sk-... # SILICONFLOW_API_KEY=sk-...
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed # SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
# SGLang Configuration (OpenAI-compatible)
# SGLANG_API_KEY=your-sglang-api-key
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
# Vercel AI Gateway Configuration # Vercel AI Gateway Configuration
# Get your API key from: https://vercel.com/ai-gateway # Get your API key from: https://vercel.com/ai-gateway
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" # Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -97,12 +93,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net # NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance # Use this to point to a self-hosted draw.io instance
# Subdirectory Deployment (Optional)
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
# Leave empty for root deployment (default)
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional) # PDF Input Feature (Optional)
# Enable PDF file upload to extract text and generate diagrams # Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable. # Enabled by default. Set to "false" to disable.

View File

@@ -19,13 +19,10 @@ export function register() {
const spanName = otelSpan.name const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans // Skip Next.js HTTP infrastructure spans
if ( if (
spanName.startsWith("POST") || spanName.startsWith("POST /") ||
spanName.startsWith("GET") || spanName.startsWith("GET /") ||
spanName.startsWith("RSC") ||
spanName.includes("BaseServer") || spanName.includes("BaseServer") ||
spanName.includes("handleRequest") || spanName.includes("handleRequest")
spanName.includes("resolve page") ||
spanName.includes("start response")
) { ) {
return false return false
} }
@@ -39,5 +36,4 @@ export function register() {
// Register globally so AI SDK's telemetry also uses this processor // Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register() tracerProvider.register()
console.log("[Langfuse] Instrumentation initialized successfully")
} }

View File

@@ -19,7 +19,6 @@ export type ProviderName =
| "openrouter" | "openrouter"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "sglang"
| "gateway" | "gateway"
interface ModelConfig { interface ModelConfig {
@@ -51,7 +50,6 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"openrouter", "openrouter",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
"sglang",
"gateway", "gateway",
] ]
@@ -95,8 +93,8 @@ function parseIntSafe(
* Supports various AI SDK providers with their unique configuration options * Supports various AI SDK providers with their unique configuration options
* *
* Environment variables: * Environment variables:
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - 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 (auto/detailed) - auto-enabled for o1/o3/o4/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_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
@@ -118,19 +116,18 @@ function buildProviderOptions(
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY 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 ( if (
modelId && modelId &&
(modelId.includes("o1") || (modelId.includes("o1") ||
modelId.includes("o3") || modelId.includes("o3") ||
modelId.includes("o4") ||
modelId.includes("gpt-5")) modelId.includes("gpt-5"))
) { ) {
options.openai = { options.openai = {
// Auto-enable reasoning summary for reasoning models // Auto-enable reasoning summary for reasoning models (default: detailed)
// Use 'auto' as default since not all models support 'detailed'
reasoningSummary: reasoningSummary:
(reasoningSummary as "auto" | "detailed") || "auto", (reasoningSummary as "none" | "brief" | "detailed") ||
"detailed",
} }
// Optionally configure reasoning effort // Optionally configure reasoning effort
@@ -153,7 +150,8 @@ function buildProviderOptions(
} }
if (reasoningSummary) { if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as options.openai.reasoningSummary = reasoningSummary as
| "auto" | "none"
| "brief"
| "detailed" | "detailed"
} }
} }
@@ -345,7 +343,6 @@ function buildProviderOptions(
case "deepseek": case "deepseek":
case "openrouter": case "openrouter":
case "siliconflow": case "siliconflow":
case "sglang":
case "gateway": { case "gateway": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
@@ -370,7 +367,6 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY", gateway: "AI_GATEWAY_API_KEY",
} }
@@ -436,7 +432,7 @@ function validateProviderCredentials(provider: ProviderName): void {
* Get the AI model based on environment variables * Get the AI model based on environment variables
* *
* Environment variables: * Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway) * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
* - AI_MODEL: The model ID/name for the selected provider * - AI_MODEL: The model ID/name for the selected provider
* *
* Provider-specific env vars: * Provider-specific env vars:
@@ -452,8 +448,6 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_API_KEY: SiliconFlow API key
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1) * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
*/ */
export function getAIModel(overrides?: ClientOverrides): ModelConfig { export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -522,7 +516,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- OPENROUTER_API_KEY for OpenRouter\n` + `- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`, `Or set AI_PROVIDER=ollama for local Ollama.`,
) )
} else { } else {
@@ -593,9 +586,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
apiKey, apiKey,
...(baseURL && { baseURL }), ...(baseURL && { baseURL }),
}) })
// Use Responses API (default) instead of .chat() to support reasoning model = customOpenAI.chat(modelId)
// for gpt-5, o1, o3, o4 models. Chat Completions API does not emit reasoning events.
model = customOpenAI(modelId)
} else { } else {
model = openai(modelId) model = openai(modelId)
} }
@@ -707,112 +698,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "sglang": {
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
const sglangProvider = createOpenAI({
apiKey,
baseURL,
// Add a custom fetch wrapper to intercept and fix the stream from sglang
fetch: async (url, options) => {
const response = await fetch(url, options)
if (!response.body) {
return response
}
// Create a transform stream to fix the non-compliant sglang stream
let buffer = ""
const decoder = new TextDecoder()
const transformStream = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
// Process all complete messages in the buffer
let messageEndPos
while (
(messageEndPos = buffer.indexOf("\n\n")) !== -1
) {
const message = buffer.substring(
0,
messageEndPos,
)
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
if (message.startsWith("data: ")) {
const jsonStr = message.substring(6).trim()
if (jsonStr === "[DONE]") {
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
continue
}
try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
if (delta) {
// Fix 1: remove invalid empty role
if (delta.role === "") {
delete delta.role
}
// Fix 2: remove non-standard reasoning_content field
if ("reasoning_content" in delta) {
delete delta.reasoning_content
}
}
// Re-serialize and forward the corrected data with the correct SSE format
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(data)}\n\n`,
),
)
} catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
} else if (message.trim() !== "") {
// Pass through other message types (e.g., 'event: ...')
controller.enqueue(
new TextEncoder().encode(
message + "\n\n",
),
)
}
}
},
flush(controller) {
// If there's anything left in the buffer, forward it.
if (buffer.trim()) {
controller.enqueue(
new TextEncoder().encode(buffer),
)
}
},
})
const transformedBody =
response.body.pipeThrough(transformStream)
// Return a new response with the transformed body
return new Response(transformedBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
})
},
})
model = sglangProvider.chat(modelId)
break
}
case "gateway": { case "gateway": {
// Vercel AI Gateway - unified access to multiple AI providers // Vercel AI Gateway - unified access to multiple AI providers
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
@@ -836,7 +721,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
) )
} }

View File

@@ -1,37 +0,0 @@
/**
* Get the base path for API calls and static assets
* This is used for subdirectory deployment support
*
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
* For root deployment, this returns ""
*
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
*/
export function getBasePath(): string {
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
if (basePath && !basePath.startsWith("/")) {
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
}
return basePath
}
/**
* Get full API endpoint URL
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
* @returns Full API path with base path prefix
*/
export function getApiEndpoint(endpoint: string): string {
const basePath = getBasePath()
return `${basePath}${endpoint}`
}
/**
* Get full static asset URL
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
* @returns Full asset path with base path prefix
*/
export function getAssetUrl(assetPath: string): string {
const basePath = getBasePath()
return `${basePath}${assetPath}`
}

View File

@@ -1,238 +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"
// 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 = new Date().toISOString().split("T")[0]
const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try {
// Atomic check-and-increment with ConditionExpression
// This prevents race conditions by failing if limits are exceeded
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } },
// Reset counts if new day/minute, then increment request count
UpdateExpression: `
SET lastResetDate = :today,
dailyReqCount = if_not_exists(dailyReqCount, :zero) + :one,
dailyTokenCount = if_not_exists(dailyTokenCount, :zero),
lastMinute = :minute,
tpmCount = if_not_exists(tpmCount, :zero),
#ttl = :ttl
`,
// Atomic condition: only succeed if ALL limits pass
// Uses attribute_not_exists for new items, then checks limits for existing items
ConditionExpression: `
(attribute_not_exists(lastResetDate) OR lastResetDate < :today OR
((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}`,
)
}
}
}

View File

@@ -14,7 +14,6 @@
"about": "About", "about": "About",
"editor": "Editor", "editor": "Editor",
"newChat": "Start fresh chat", "newChat": "Start fresh chat",
"github": "GitHub",
"settings": "Settings", "settings": "Settings",
"hidePanel": "Hide chat panel (Ctrl+B)", "hidePanel": "Hide chat panel (Ctrl+B)",
"showPanel": "Show chat panel (Ctrl+B)", "showPanel": "Show chat panel (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "Overrides", "overrides": "Overrides",
"clearSettings": "Clear Settings", "clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default", "useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme", "theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.", "themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style", "drawioStyle": "DrawIO Style",
@@ -150,7 +147,6 @@
"tokenLimit": "Daily Token Limit Reached", "tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit", "tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.", "tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.", "messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.", "messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.", "tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
@@ -202,47 +198,6 @@
"apiKeyStored": "API keys are stored locally in your browser", "apiKeyStored": "API keys are stored locally in your browser",
"test": "Test", "test": "Test",
"validationError": "Validation failed", "validationError": "Validation failed",
"addModelFirst": "Add at least one model to validate", "addModelFirst": "Add at least one model to validate"
"providers": "Providers",
"addProviderHint": "Add a provider to get started",
"verified": "Verified",
"configuration": "Configuration",
"displayName": "Display Name",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
"selectRegion": "Select region",
"apiKey": "API Key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"customEndpoint": "Custom endpoint URL",
"models": "Models",
"customModelId": "Custom model ID...",
"allAdded": "All added",
"suggested": "Suggested",
"noModelsConfigured": "No models configured",
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"configureProviders": "Configure AI Providers",
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type provider name...",
"modelsConfiguredCount": "{count} model(s) configured",
"validationFailedCount": "{count} model(s) failed validation",
"cancel": "Cancel",
"delete": "Delete",
"clickToChange": "(click to change)",
"usingServerDefault": "Using server default model",
"selectModel": "Select Model",
"searchModels": "Search models...",
"noVerifiedModels": "No verified models. Test your models first.",
"noModelsFound": "No models found.",
"default": "Default",
"serverDefault": "Server Default",
"configureModels": "Configure Models...",
"onlyVerifiedShown": "Only verified models are shown"
} }
} }

View File

@@ -14,7 +14,6 @@
"about": "概要", "about": "概要",
"editor": "エディタ", "editor": "エディタ",
"newChat": "新しいチャットを開始", "newChat": "新しいチャットを開始",
"github": "GitHub",
"settings": "設定", "settings": "設定",
"hidePanel": "チャットパネルを非表示 (Ctrl+B)", "hidePanel": "チャットパネルを非表示 (Ctrl+B)",
"showPanel": "チャットパネルを表示 (Ctrl+B)", "showPanel": "チャットパネルを表示 (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "上書き", "overrides": "上書き",
"clearSettings": "設定をクリア", "clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用", "useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ", "theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。", "themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル", "drawioStyle": "DrawIO スタイル",
@@ -150,7 +147,6 @@
"tokenLimit": "1日のトークン制限に達しました", "tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限", "tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。", "tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。", "messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。", "messageToken": "おっと — このデモの1日のトークン制限に達しました個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。", "tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
@@ -202,47 +198,6 @@
"apiKeyStored": "APIキーはブラウザにローカル保存されます", "apiKeyStored": "APIキーはブラウザにローカル保存されます",
"test": "テスト", "test": "テスト",
"validationError": "検証に失敗しました", "validationError": "検証に失敗しました",
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください", "addModelFirst": "検証するには少なくとも1つのモデルを追加してください"
"providers": "プロバイダー",
"addProviderHint": "プロバイダーを追加して開始",
"verified": "検証済み",
"configuration": "設定",
"displayName": "表示名",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
"selectRegion": "リージョンを選択",
"apiKey": "API キー",
"enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
"suggested": "おすすめ",
"noModelsConfigured": "モデルが設定されていません",
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"configureProviders": "AI プロバイダーを設定",
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
"validationFailedCount": "{count} 個のモデルの検証に失敗",
"cancel": "キャンセル",
"delete": "削除",
"clickToChange": "(クリックして変更)",
"usingServerDefault": "サーバーデフォルトモデルを使用中",
"selectModel": "モデルを選択",
"searchModels": "モデルを検索...",
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
"noModelsFound": "モデルが見つかりません。",
"default": "デフォルト",
"serverDefault": "サーバーデフォルト",
"configureModels": "モデルを設定...",
"onlyVerifiedShown": "検証済みのモデルのみ表示"
} }
} }

View File

@@ -14,7 +14,6 @@
"about": "关于", "about": "关于",
"editor": "编辑器", "editor": "编辑器",
"newChat": "开始新对话", "newChat": "开始新对话",
"github": "GitHub",
"settings": "设置", "settings": "设置",
"hidePanel": "隐藏聊天面板 (Ctrl+B)", "hidePanel": "隐藏聊天面板 (Ctrl+B)",
"showPanel": "显示聊天面板 (Ctrl+B)", "showPanel": "显示聊天面板 (Ctrl+B)",
@@ -88,8 +87,6 @@
"overrides": "覆盖", "overrides": "覆盖",
"clearSettings": "清除设置", "clearSettings": "清除设置",
"useServerDefault": "使用服务器默认值", "useServerDefault": "使用服务器默认值",
"language": "语言",
"languageDescription": "选择界面语言。",
"theme": "主题", "theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。", "themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式", "drawioStyle": "DrawIO 样式",
@@ -150,7 +147,6 @@
"tokenLimit": "已达每日令牌限制", "tokenLimit": "已达每日令牌限制",
"tpmLimit": "速率限制", "tpmLimit": "速率限制",
"tpmMessage": "请求过多。请稍等片刻。", "tpmMessage": "请求过多。请稍等片刻。",
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。", "messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。", "messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。", "tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
@@ -202,47 +198,6 @@
"apiKeyStored": "API 密钥存储在您的浏览器本地", "apiKeyStored": "API 密钥存储在您的浏览器本地",
"test": "测试", "test": "测试",
"validationError": "验证失败", "validationError": "验证失败",
"addModelFirst": "请先添加至少一个模型以进行验证", "addModelFirst": "请先添加至少一个模型以进行验证"
"providers": "提供商",
"addProviderHint": "添加提供商即可开始使用",
"verified": "已验证",
"configuration": "配置",
"displayName": "显示名称",
"awsAccessKeyId": "AWS 访问密钥 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 区域",
"selectRegion": "选择区域",
"apiKey": "API 密钥",
"enterApiKey": "输入您的 API 密钥",
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"customEndpoint": "自定义端点 URL",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
"suggested": "推荐",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能为空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
"typeToConfirm": "输入 \"{name}\" 以确认",
"typeProviderName": "输入提供商名称...",
"modelsConfiguredCount": "已配置 {count} 个模型",
"validationFailedCount": "{count} 个模型验证失败",
"cancel": "取消",
"delete": "删除",
"clickToChange": "(点击更改)",
"usingServerDefault": "使用服务器默认模型",
"selectModel": "选择模型",
"searchModels": "搜索模型...",
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
"noModelsFound": "未找到模型。",
"default": "默认",
"serverDefault": "服务器默认",
"configureModels": "配置模型...",
"onlyVerifiedShown": "仅显示已验证的模型"
} }
} }

View File

@@ -21,11 +21,9 @@ export function getLangfuseClient(): LangfuseClient | null {
return langfuseClient return langfuseClient
} }
// Check if Langfuse is configured (both keys required) // Check if Langfuse is configured
export function isLangfuseEnabled(): boolean { export function isLangfuseEnabled(): boolean {
return !!( return !!process.env.LANGFUSE_PUBLIC_KEY
process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY
)
} }
// Update trace with input data at the start of request // 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 // Update trace with output and end the span
// Note: AI SDK 6 telemetry automatically reports token usage on its spans, export function setTraceOutput(
// so we only need to set the output text and close our wrapper span output: string,
export function setTraceOutput(output: string) { usage?: { promptTokens?: number; completionTokens?: number },
) {
if (!isLangfuseEnabled()) return if (!isLangfuseEnabled()) return
updateActiveTrace({ output }) updateActiveTrace({ output })
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
const activeSpan = api.trace.getActiveSpan() const activeSpan = api.trace.getActiveSpan()
if (activeSpan) { 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() activeSpan.end()
} }
} }

View File

@@ -1,10 +1,9 @@
"use client" "use client"
import { useCallback } from "react" import { useCallback, useMemo } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { QuotaLimitToast } from "@/components/quota-limit-toast" import { QuotaLimitToast } from "@/components/quota-limit-toast"
import { useDictionary } from "@/hooks/use-dictionary" import { STORAGE_KEYS } from "@/lib/storage"
import { formatMessage } from "@/lib/i18n/utils"
export interface QuotaConfig { export interface QuotaConfig {
dailyRequestLimit: number dailyRequestLimit: number
@@ -12,18 +11,131 @@ export interface QuotaConfig {
tpmLimit: number tpmLimit: number
} }
export interface QuotaCheckResult {
allowed: boolean
remaining: number
used: number
}
/** /**
* Hook for displaying quota limit toasts. * Hook for managing request/token quotas and rate limiting.
* Server-side handles actual quota enforcement via DynamoDB. * Handles three types of limits:
* This hook only provides UI feedback when limits are exceeded. * - 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): { export function useQuotaManager(config: QuotaConfig): {
hasOwnApiKey: () => boolean
checkDailyLimit: () => QuotaCheckResult
checkTokenLimit: () => QuotaCheckResult
checkTPMLimit: () => QuotaCheckResult
incrementRequestCount: () => void
incrementTokenCount: (tokens: number) => void
incrementTPMCount: (tokens: number) => void
showQuotaLimitToast: () => void showQuotaLimitToast: () => void
showTokenLimitToast: (used: number) => void showTokenLimitToast: (used: number) => void
showTPMLimitToast: () => void showTPMLimitToast: () => void
} { } {
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
const dict = useDictionary()
// 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,
}
}
},
[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) // Show quota limit toast (request-based)
const showQuotaLimitToast = useCallback(() => { const showQuotaLimitToast = useCallback(() => {
@@ -39,6 +151,30 @@ export function useQuotaManager(config: QuotaConfig): {
) )
}, [dailyRequestLimit]) }, [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 // Show token limit toast
const showTokenLimitToast = useCallback( const showTokenLimitToast = useCallback(
(used: number) => { (used: number) => {
@@ -57,18 +193,53 @@ export function useQuotaManager(config: QuotaConfig): {
[dailyTokenLimit], [dailyTokenLimit],
) )
// 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 // Show TPM limit toast
const showTPMLimitToast = useCallback(() => { const showTPMLimitToast = useCallback(() => {
const limitDisplay = const limitDisplay =
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit) tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
const message = formatMessage(dict.quota.tpmMessageDetailed, { toast.error(
limit: limitDisplay, `Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
seconds: 60, { duration: 8000 },
}) )
toast.error(message, { duration: 8000 }) }, [tpmLimit])
}, [tpmLimit, dict])
return { return {
// Check functions
hasOwnApiKey,
checkDailyLimit,
checkTokenLimit,
checkTPMLimit,
// Increment functions
incrementRequestCount,
incrementTokenCount,
incrementTPMCount,
// Toast functions
showQuotaLimitToast, showQuotaLimitToast,
showTokenLimitToast, showTokenLimitToast,
showTPMLimitToast, showTPMLimitToast,

View File

@@ -61,47 +61,6 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>") return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
} }
/**
* 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] })
}
// 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")
}
// ============================================================================ // ============================================================================
// XML Parsing Helpers // XML Parsing Helpers
// ============================================================================ // ============================================================================

View File

@@ -4,16 +4,9 @@ import packageJson from "./package.json"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: "standalone", output: "standalone",
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
env: { env: {
APP_VERSION: packageJson.version, APP_VERSION: packageJson.version,
}, },
// Include instrumentation.ts in standalone build for Langfuse telemetry
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
},
} }
export default nextConfig export default nextConfig

1378
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.6", "version": "0.4.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -24,22 +24,21 @@
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux" "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --mac --win --linux"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^3.0.70",
"@ai-sdk/anthropic": "^3.0.0", "@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/azure": "^3.0.0", "@ai-sdk/azure": "^2.0.69",
"@ai-sdk/deepseek": "^2.0.0", "@ai-sdk/deepseek": "^1.0.30",
"@ai-sdk/gateway": "^3.0.0", "@ai-sdk/gateway": "^2.0.21",
"@ai-sdk/google": "^3.0.0", "@ai-sdk/google": "^2.0.0",
"@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai": "^2.0.19",
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^2.0.107",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@formatjs/intl-localematcher": "^0.7.2", "@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.2.3",
"@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -54,7 +53,7 @@
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.1", "ai": "^5.0.89",
"base-64": "^1.0.0", "base-64": "^1.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -112,10 +111,5 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"wait-on": "^9.0.3" "wait-on": "^9.0.3"
},
"overrides": {
"@openrouter/ai-sdk-provider": {
"ai": "^6.0.1"
}
} }
} }