mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
1 Commits
d3be96de79
...
fix/openai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32f7383002 |
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
142
app/globals.css
142
app/globals.css
@@ -144,68 +144,6 @@
|
|||||||
--sidebar-ring: oklch(0.7 0.16 265);
|
--sidebar-ring: oklch(0.7 0.16 265);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
REFINED MINIMAL DESIGN SYSTEM
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* Surface layers for depth */
|
|
||||||
--surface-0: oklch(1 0 0);
|
|
||||||
--surface-1: oklch(0.985 0.002 240);
|
|
||||||
--surface-2: oklch(0.97 0.004 240);
|
|
||||||
--surface-elevated: oklch(1 0 0);
|
|
||||||
|
|
||||||
/* Subtle borders */
|
|
||||||
--border-subtle: oklch(0.94 0.008 260);
|
|
||||||
--border-default: oklch(0.91 0.012 260);
|
|
||||||
|
|
||||||
/* Interactive states */
|
|
||||||
--interactive-hover: oklch(0.96 0.015 260);
|
|
||||||
--interactive-active: oklch(0.93 0.02 265);
|
|
||||||
|
|
||||||
/* Success state */
|
|
||||||
--success: oklch(0.65 0.18 145);
|
|
||||||
--success-muted: oklch(0.95 0.03 145);
|
|
||||||
|
|
||||||
/* Animation timing */
|
|
||||||
--duration-fast: 120ms;
|
|
||||||
--duration-normal: 200ms;
|
|
||||||
--duration-slow: 300ms;
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--surface-0: oklch(0.15 0.015 260);
|
|
||||||
--surface-1: oklch(0.18 0.015 260);
|
|
||||||
--surface-2: oklch(0.22 0.015 260);
|
|
||||||
--surface-elevated: oklch(0.25 0.015 260);
|
|
||||||
|
|
||||||
--border-subtle: oklch(0.25 0.012 260);
|
|
||||||
--border-default: oklch(0.3 0.015 260);
|
|
||||||
|
|
||||||
--interactive-hover: oklch(0.25 0.02 265);
|
|
||||||
--interactive-active: oklch(0.3 0.025 270);
|
|
||||||
|
|
||||||
--success: oklch(0.7 0.16 145);
|
|
||||||
--success-muted: oklch(0.25 0.04 145);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expose surface colors to Tailwind */
|
|
||||||
@theme inline {
|
|
||||||
--color-surface-0: var(--surface-0);
|
|
||||||
--color-surface-1: var(--surface-1);
|
|
||||||
--color-surface-2: var(--surface-2);
|
|
||||||
--color-surface-elevated: var(--surface-elevated);
|
|
||||||
--color-border-subtle: var(--border-subtle);
|
|
||||||
--color-border-default: var(--border-default);
|
|
||||||
--color-interactive-hover: var(--interactive-hover);
|
|
||||||
--color-interactive-active: var(--interactive-active);
|
|
||||||
--color-success: var(--success);
|
|
||||||
--color-success-muted: var(--success-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -319,83 +257,3 @@
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
REFINED DIALOG STYLES
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
/* Refined dialog shadow - multi-layer soft shadow */
|
|
||||||
.shadow-dialog {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
|
||||||
0 2px 4px oklch(0 0 0 / 0.02),
|
|
||||||
0 12px 24px oklch(0 0 0 / 0.06),
|
|
||||||
0 24px 48px oklch(0 0 0 / 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .shadow-dialog {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
|
||||||
0 2px 4px oklch(0 0 0 / 0.2),
|
|
||||||
0 12px 24px oklch(0 0 0 / 0.3),
|
|
||||||
0 24px 48px oklch(0 0 0 / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dialog animations */
|
|
||||||
@keyframes dialog-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.96);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dialog-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -48%) scale(0.96);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-dialog-in {
|
|
||||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-dialog-out {
|
|
||||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Check pop animation for validation success */
|
|
||||||
@keyframes check-pop {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.8);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-check-pop {
|
|
||||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduced motion support */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.animate-dialog-in,
|
|
||||||
.animate-dialog-out,
|
|
||||||
.animate-check-pop {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { getApiEndpoint } from "@/lib/base-path"
|
|||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
@@ -316,28 +315,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 +347,7 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<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,20 +360,19 @@ 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,
|
||||||
)
|
)
|
||||||
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
|
if (showToast) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Diagram validation failed. Please try regenerating.",
|
"Diagram validation failed. Please try regenerating.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"[ChatMessageDisplay] Error processing XML:",
|
"[ChatMessageDisplay] Error processing XML:",
|
||||||
@@ -636,10 +603,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) => {
|
||||||
|
|||||||
@@ -76,7 +76,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.
|
||||||
@@ -217,8 +216,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
|
||||||
@@ -556,43 +553,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// Handle server-side quota limit (429 response)
|
|
||||||
// AI SDK puts the full response body in error.message for non-OK responses
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(error.message)
|
|
||||||
if (data.type === "request") {
|
|
||||||
quotaManager.showQuotaLimitToast(data.used, data.limit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.type === "token") {
|
|
||||||
quotaManager.showTokenLimitToast(data.used, data.limit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (data.type === "tpm") {
|
|
||||||
quotaManager.showTPMLimitToast(data.limit)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not JSON, fall through to string matching for backwards compatibility
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to string matching
|
|
||||||
if (error.message.includes("Daily request limit")) {
|
|
||||||
quotaManager.showQuotaLimitToast()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (error.message.includes("Daily token limit")) {
|
|
||||||
quotaManager.showTokenLimitToast()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
error.message.includes("Rate limit exceeded") ||
|
|
||||||
error.message.includes("tokens per minute")
|
|
||||||
) {
|
|
||||||
quotaManager.showTPMLimitToast()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Silence access code error in console since it's handled by UI
|
// 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)
|
||||||
@@ -669,6 +629,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
|
||||||
@@ -680,25 +656,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) {
|
||||||
@@ -713,6 +679,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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -929,6 +912,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
|
||||||
@@ -1006,7 +992,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,
|
||||||
@@ -1015,7 +1024,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()
|
||||||
@@ -1056,6 +1064,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)
|
||||||
@@ -1143,8 +1152,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) => {
|
||||||
@@ -1186,8 +1200,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)
|
||||||
|
|||||||
@@ -103,40 +103,41 @@ function ProviderLogo({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration section with title and optional action
|
// Reusable validation button component
|
||||||
function ConfigSection({
|
function ValidationButton({
|
||||||
title,
|
status,
|
||||||
icon: Icon,
|
onClick,
|
||||||
action,
|
disabled,
|
||||||
children,
|
dict,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
status: ValidationStatus
|
||||||
icon: React.ComponentType<{ className?: string }>
|
onClick: () => void
|
||||||
action?: React.ReactNode
|
disabled: boolean
|
||||||
children: React.ReactNode
|
dict: ReturnType<typeof useDictionary>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Button
|
||||||
<div className="flex items-center justify-between">
|
variant={status === "success" ? "outline" : "default"}
|
||||||
<div className="flex items-center gap-2">
|
size="sm"
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
onClick={onClick}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
disabled={disabled}
|
||||||
{title}
|
className={cn(
|
||||||
</span>
|
"h-9 px-4 min-w-[80px]",
|
||||||
</div>
|
status === "success" &&
|
||||||
{action}
|
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||||
</div>
|
)}
|
||||||
{children}
|
>
|
||||||
</div>
|
{status === "validating" ? (
|
||||||
)
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
}
|
) : status === "success" ? (
|
||||||
|
<>
|
||||||
// Card wrapper with subtle depth
|
<Check className="h-4 w-4 mr-1.5" />
|
||||||
function ConfigCard({ children }: { children: React.ReactNode }) {
|
{dict.modelConfig.verified}
|
||||||
return (
|
</>
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/50 p-5 space-y-5">
|
) : (
|
||||||
{children}
|
dict.modelConfig.test
|
||||||
</div>
|
)}
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +154,7 @@ export function ModelConfigDialog({
|
|||||||
const [validationStatus, setValidationStatus] =
|
const [validationStatus, setValidationStatus] =
|
||||||
useState<ValidationStatus>("idle")
|
useState<ValidationStatus>("idle")
|
||||||
const [validationError, setValidationError] = useState<string>("")
|
const [validationError, setValidationError] = useState<string>("")
|
||||||
|
const [scrollState, setScrollState] = useState({ top: false, bottom: true })
|
||||||
const [customModelInput, setCustomModelInput] = useState("")
|
const [customModelInput, setCustomModelInput] = useState("")
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const validationResetTimeoutRef = useRef<ReturnType<
|
const validationResetTimeoutRef = useRef<ReturnType<
|
||||||
@@ -184,6 +186,26 @@ export function ModelConfigDialog({
|
|||||||
(p) => p.id === selectedProviderId,
|
(p) => p.id === selectedProviderId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Track scroll position for gradient shadows
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollEl = scrollRef.current?.querySelector(
|
||||||
|
"[data-radix-scroll-area-viewport]",
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (!scrollEl) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollEl
|
||||||
|
setScrollState({
|
||||||
|
top: scrollTop > 10,
|
||||||
|
bottom: scrollTop < scrollHeight - clientHeight - 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() // Initial check
|
||||||
|
scrollEl.addEventListener("scroll", handleScroll)
|
||||||
|
return () => scrollEl.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [selectedProvider])
|
||||||
|
|
||||||
// Cleanup validation reset timeout on unmount
|
// Cleanup validation reset timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -368,35 +390,34 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-4xl h-[80vh] max-h-[800px] overflow-hidden flex flex-col gap-0 p-0">
|
<DialogContent className="sm:max-w-3xl h-[75vh] max-h-[700px] overflow-hidden flex flex-col gap-0 p-0">
|
||||||
{/* Header */}
|
<DialogHeader className="px-6 pt-6 pb-4 border-b bg-gradient-to-r from-primary/5 via-primary/3 to-transparent">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
<DialogTitle className="flex items-center gap-2.5 text-xl font-semibold">
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<div className="p-1.5 rounded-lg bg-primary/10">
|
||||||
<div className="p-2 rounded-xl bg-surface-2">
|
|
||||||
<Server className="h-5 w-5 text-primary" />
|
<Server className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{dict.modelConfig?.title || "AI Model Configuration"}
|
{dict.modelConfig?.title || "AI Model Configuration"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="mt-1">
|
<DialogDescription className="text-sm">
|
||||||
{dict.modelConfig?.description ||
|
{dict.modelConfig?.description ||
|
||||||
"Configure multiple AI providers and models for your workspace"}
|
"Configure multiple AI providers and models for your workspace"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden border-t border-border-subtle">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
{/* Provider List (Left Sidebar) */}
|
{/* Provider List (Left Sidebar) */}
|
||||||
<div className="w-60 shrink-0 flex flex-col bg-surface-1/50 border-r border-border-subtle">
|
<div className="w-56 flex-shrink-0 flex flex-col border-r bg-muted/20">
|
||||||
<div className="px-4 py-3">
|
<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}
|
{dict.modelConfig.providers}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-2">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 pb-2">
|
<div className="p-2">
|
||||||
{config.providers.length === 0 ? (
|
{config.providers.length === 0 ? (
|
||||||
<div className="px-3 py-8 text-center">
|
<div className="px-3 py-8 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-3">
|
||||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
<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">
|
||||||
@@ -404,7 +425,8 @@ export function ModelConfigDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
config.providers.map((provider) => (
|
<div className="flex flex-col gap-1">
|
||||||
|
{config.providers.map((provider) => (
|
||||||
<button
|
<button
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -420,42 +442,29 @@ export function ModelConfigDialog({
|
|||||||
setShowApiKey(false)
|
setShowApiKey(false)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
|
"group flex items-center gap-3 px-3 py-2.5 rounded-lg text-left text-sm transition-all duration-150 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
"text-left text-sm transition-all duration-150",
|
|
||||||
"hover:bg-interactive-hover",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
selectedProviderId ===
|
selectedProviderId ===
|
||||||
provider.id &&
|
provider.id &&
|
||||||
"bg-surface-0 shadow-sm ring-1 ring-border-subtle",
|
"bg-background shadow-sm ring-1 ring-border",
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-8 h-8 rounded-lg flex items-center justify-center",
|
|
||||||
"bg-surface-2 transition-colors duration-150",
|
|
||||||
selectedProviderId ===
|
|
||||||
provider.id &&
|
|
||||||
"bg-primary/10",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
provider={provider.provider}
|
provider={provider.provider}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span className="flex-1 truncate font-medium">
|
<span className="flex-1 truncate font-medium">
|
||||||
{getProviderDisplayName(
|
{getProviderDisplayName(
|
||||||
provider,
|
provider,
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{provider.validated ? (
|
{provider.validated ? (
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-success-muted">
|
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-emerald-500/10">
|
||||||
<Check className="h-3 w-3 text-success" />
|
<Check className="h-3 w-3 text-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-muted-foreground/50 transition-transform duration-150",
|
"h-4 w-4 text-muted-foreground/50 transition-transform",
|
||||||
selectedProviderId ===
|
selectedProviderId ===
|
||||||
provider.id &&
|
provider.id &&
|
||||||
"translate-x-0.5",
|
"translate-x-0.5",
|
||||||
@@ -463,19 +472,20 @@ export function ModelConfigDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Add Provider */}
|
{/* Add Provider */}
|
||||||
<div className="p-3 border-t border-border-subtle">
|
<div className="p-2 border-t">
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
handleAddProvider(v as ProviderName)
|
handleAddProvider(v as ProviderName)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full h-9 rounded-xl bg-surface-0 border-border-subtle hover:bg-interactive-hover">
|
<SelectTrigger className="h-9 bg-background hover:bg-accent">
|
||||||
<Plus className="h-4 w-4 mr-2 text-muted-foreground" />
|
<Plus className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -504,23 +514,41 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider Details (Right Panel) */}
|
{/* Provider Details (Right Panel) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden relative">
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<>
|
<>
|
||||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
{/* Top gradient shadow */}
|
||||||
<div className="p-6 space-y-8">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
|
||||||
|
scrollState.top
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Bottom gradient shadow */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-background to-transparent z-10 pointer-events-none transition-opacity duration-200",
|
||||||
|
scrollState.bottom
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ScrollArea className="h-full" ref={scrollRef}>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
{/* Provider Header */}
|
{/* Provider Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-surface-2">
|
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-muted">
|
||||||
<ProviderLogo
|
<ProviderLogo
|
||||||
provider={
|
provider={
|
||||||
selectedProvider.provider
|
selectedProvider.provider
|
||||||
}
|
}
|
||||||
className="h-6 w-6"
|
className="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-lg tracking-tight">
|
<h3 className="font-semibold text-base">
|
||||||
{
|
{
|
||||||
PROVIDER_INFO[
|
PROVIDER_INFO[
|
||||||
selectedProvider
|
selectedProvider
|
||||||
@@ -528,7 +556,7 @@ export function ModelConfigDialog({
|
|||||||
].label
|
].label
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{selectedProvider.models
|
{selectedProvider.models
|
||||||
.length === 0
|
.length === 0
|
||||||
? dict.modelConfig
|
? dict.modelConfig
|
||||||
@@ -545,8 +573,8 @@ export function ModelConfigDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedProvider.validated && (
|
{selectedProvider.validated && (
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
|
<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 animate-check-pop" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
{
|
{
|
||||||
dict.modelConfig
|
dict.modelConfig
|
||||||
@@ -558,13 +586,18 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Section */}
|
{/* Configuration Section */}
|
||||||
<ConfigSection
|
<div className="space-y-4">
|
||||||
title={
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
dict.modelConfig.configuration
|
<Settings2 className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.configuration
|
||||||
}
|
}
|
||||||
icon={Settings2}
|
</span>
|
||||||
>
|
</div>
|
||||||
<ConfigCard>
|
|
||||||
|
<div className="rounded-xl border bg-card p-4 space-y-4">
|
||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
@@ -823,7 +856,7 @@ export function ModelConfigDialog({
|
|||||||
"h-9 px-4",
|
"h-9 px-4",
|
||||||
validationStatus ===
|
validationStatus ===
|
||||||
"success" &&
|
"success" &&
|
||||||
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{validationStatus ===
|
{validationStatus ===
|
||||||
@@ -832,7 +865,7 @@ export function ModelConfigDialog({
|
|||||||
) : validationStatus ===
|
) : validationStatus ===
|
||||||
"success" ? (
|
"success" ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
<Check className="h-4 w-4 mr-1.5" />
|
||||||
{
|
{
|
||||||
dict
|
dict
|
||||||
.modelConfig
|
.modelConfig
|
||||||
@@ -942,7 +975,7 @@ export function ModelConfigDialog({
|
|||||||
"h-9 px-4",
|
"h-9 px-4",
|
||||||
validationStatus ===
|
validationStatus ===
|
||||||
"success" &&
|
"success" &&
|
||||||
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
|
"text-emerald-600 border-emerald-200 dark:border-emerald-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{validationStatus ===
|
{validationStatus ===
|
||||||
@@ -951,7 +984,7 @@ export function ModelConfigDialog({
|
|||||||
) : validationStatus ===
|
) : validationStatus ===
|
||||||
"success" ? (
|
"success" ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-4 w-4 mr-1.5 animate-check-pop" />
|
<Check className="h-4 w-4 mr-1.5" />
|
||||||
{
|
{
|
||||||
dict
|
dict
|
||||||
.modelConfig
|
.modelConfig
|
||||||
@@ -1020,19 +1053,26 @@ export function ModelConfigDialog({
|
|||||||
.modelConfig
|
.modelConfig
|
||||||
.customEndpoint
|
.customEndpoint
|
||||||
}
|
}
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
className="h-9 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ConfigCard>
|
</div>
|
||||||
</ConfigSection>
|
</div>
|
||||||
|
|
||||||
{/* Models Section */}
|
{/* Models Section */}
|
||||||
<ConfigSection
|
<div className="space-y-4">
|
||||||
title={dict.modelConfig.models}
|
<div className="flex items-center justify-between">
|
||||||
icon={Sparkles}
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
action={
|
<Sparkles className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.models
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -1048,6 +1088,7 @@ export function ModelConfigDialog({
|
|||||||
e.target
|
e.target
|
||||||
.value,
|
.value,
|
||||||
)
|
)
|
||||||
|
// Clear duplicate error when typing
|
||||||
if (
|
if (
|
||||||
duplicateError
|
duplicateError
|
||||||
) {
|
) {
|
||||||
@@ -1076,11 +1117,12 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-44 rounded-lg font-mono text-xs",
|
"h-8 w-48 font-mono text-xs",
|
||||||
duplicateError &&
|
duplicateError &&
|
||||||
"border-destructive focus-visible:ring-destructive",
|
"border-destructive focus-visible:ring-destructive",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Show duplicate error for custom model input */}
|
||||||
{duplicateError && (
|
{duplicateError && (
|
||||||
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
|
<p className="absolute top-full left-0 mt-1 text-[11px] text-destructive">
|
||||||
{duplicateError}
|
{duplicateError}
|
||||||
@@ -1090,7 +1132,7 @@ export function ModelConfigDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 rounded-lg"
|
className="h-8"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (
|
if (
|
||||||
customModelInput.trim()
|
customModelInput.trim()
|
||||||
@@ -1127,7 +1169,7 @@ export function ModelConfigDialog({
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
|
<SelectTrigger className="w-32 h-8 hover:bg-accent">
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{availableSuggestions.length ===
|
{availableSuggestions.length ===
|
||||||
0
|
0
|
||||||
@@ -1160,14 +1202,14 @@ export function ModelConfigDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
>
|
|
||||||
{/* Model List */}
|
{/* Model List */}
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
|
<div className="rounded-xl border bg-card overflow-hidden min-h-[120px]">
|
||||||
{selectedProvider.models
|
{selectedProvider.models
|
||||||
.length === 0 ? (
|
.length === 0 ? (
|
||||||
<div className="p-6 text-center h-full flex flex-col items-center justify-center">
|
<div className="p-4 text-center h-full flex flex-col items-center justify-center">
|
||||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-muted mb-2">
|
||||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
<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">
|
||||||
@@ -1178,7 +1220,7 @@ export function ModelConfigDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y">
|
||||||
{selectedProvider.models.map(
|
{selectedProvider.models.map(
|
||||||
(model, index) => (
|
(model, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -1186,7 +1228,16 @@ export function ModelConfigDialog({
|
|||||||
model.id
|
model.id
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors duration-150 hover:bg-interactive-hover/50",
|
"transition-colors hover:bg-muted/30",
|
||||||
|
index ===
|
||||||
|
0 &&
|
||||||
|
"rounded-t-xl",
|
||||||
|
index ===
|
||||||
|
selectedProvider
|
||||||
|
.models
|
||||||
|
.length -
|
||||||
|
1 &&
|
||||||
|
"rounded-b-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 p-3 min-w-0">
|
<div className="flex items-center gap-3 p-3 min-w-0">
|
||||||
@@ -1213,8 +1264,8 @@ export function ModelConfigDialog({
|
|||||||
) : model.validated ===
|
) : model.validated ===
|
||||||
true ? (
|
true ? (
|
||||||
// Valid
|
// Valid
|
||||||
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
|
<div className="w-full h-full rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
||||||
<Check className="h-4 w-4 text-success" />
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
</div>
|
</div>
|
||||||
) : model.validated ===
|
) : model.validated ===
|
||||||
false ? (
|
false ? (
|
||||||
@@ -1415,7 +1466,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ConfigSection>
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
@@ -1425,7 +1476,7 @@ export function ModelConfigDialog({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteConfirmOpen(true)
|
setDeleteConfirmOpen(true)
|
||||||
}
|
}
|
||||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
|
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
{
|
{
|
||||||
@@ -1439,10 +1490,10 @@ export function ModelConfigDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
<div className="h-full flex flex-col items-center justify-center p-8 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-4">
|
||||||
<Server className="h-8 w-8 text-muted-foreground" />
|
<Server className="h-8 w-8 text-primary/60" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-lg tracking-tight mb-1">
|
<h3 className="font-semibold mb-1">
|
||||||
{dict.modelConfig.configureProviders}
|
{dict.modelConfig.configureProviders}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-xs">
|
<p className="text-sm text-muted-foreground max-w-xs">
|
||||||
@@ -1454,7 +1505,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-6 py-3 border-t border-border-subtle bg-surface-1/30 shrink-0">
|
<div className="px-6 py-3 border-t bg-muted/20">
|
||||||
<p className="text-xs text-muted-foreground text-center flex items-center justify-center gap-1.5">
|
<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}
|
{dict.modelConfig.apiKeyStored}
|
||||||
|
|||||||
@@ -24,32 +24,6 @@ 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 { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// Reusable setting item component for consistent layout
|
|
||||||
function SettingItem({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
|
||||||
<div className="space-y-0.5 pr-4">
|
|
||||||
<Label className="text-sm font-medium">{label}</Label>
|
|
||||||
{description && (
|
|
||||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||||
en: "English",
|
en: "English",
|
||||||
@@ -203,76 +177,60 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<DialogContent className="sm:max-w-md">
|
||||||
{/* Header */}
|
<DialogHeader>
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
<DialogDescription className="mt-1">
|
<DialogDescription>
|
||||||
{dict.settings.description}
|
{dict.settings.description}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
{/* Content */}
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{/* Access Code (conditional) */}
|
|
||||||
{accessCodeRequired && (
|
{accessCodeRequired && (
|
||||||
<div className="py-4 first:pt-0 space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-0.5">
|
<Label htmlFor="access-code">
|
||||||
<Label
|
|
||||||
htmlFor="access-code"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{dict.settings.accessCode}
|
{dict.settings.accessCode}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.settings.accessCodeDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="access-code"
|
id="access-code"
|
||||||
type="password"
|
type="password"
|
||||||
value={accessCode}
|
value={accessCode}
|
||||||
onChange={(e) =>
|
onChange={(e) => setAccessCode(e.target.value)}
|
||||||
setAccessCode(e.target.value)
|
|
||||||
}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
dict.settings.accessCodePlaceholder
|
dict.settings.accessCodePlaceholder
|
||||||
}
|
}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="h-9"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isVerifying || !accessCode.trim()}
|
disabled={isVerifying || !accessCode.trim()}
|
||||||
className="h-9 px-4 rounded-xl"
|
|
||||||
>
|
>
|
||||||
{isVerifying ? "..." : dict.common.save}
|
{isVerifying ? "..." : dict.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.accessCodeDescription}
|
||||||
|
</p>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-[0.8rem] text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.language}
|
<Label htmlFor="language-select">
|
||||||
description={dict.settings.languageDescription}
|
{dict.settings.language}
|
||||||
>
|
</Label>
|
||||||
<Select
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
value={currentLang}
|
{dict.settings.languageDescription}
|
||||||
onValueChange={changeLanguage}
|
</p>
|
||||||
>
|
</div>
|
||||||
<SelectTrigger
|
<Select value={currentLang} onValueChange={changeLanguage}>
|
||||||
id="language-select"
|
<SelectTrigger id="language-select" className="w-32">
|
||||||
className="w-[120px] h-9 rounded-xl"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -283,19 +241,22 @@ function SettingsContent({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
|
||||||
{/* Theme */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.theme}
|
<Label htmlFor="theme-toggle">
|
||||||
description={dict.settings.themeDescription}
|
{dict.settings.theme}
|
||||||
>
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.themeDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleDarkMode}
|
onClick={onToggleDarkMode}
|
||||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
|
||||||
>
|
>
|
||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -303,35 +264,42 @@ function SettingsContent({
|
|||||||
<Moon className="h-4 w-4" />
|
<Moon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
|
||||||
{/* Draw.io Style */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.drawioStyle}
|
<Label htmlFor="drawio-ui">
|
||||||
description={`${dict.settings.drawioStyleDescription} ${
|
{dict.settings.drawioStyle}
|
||||||
drawioUi === "min"
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.drawioStyleDescription}{" "}
|
||||||
|
{drawioUi === "min"
|
||||||
? dict.settings.minimal
|
? dict.settings.minimal
|
||||||
: dict.settings.sketch
|
: dict.settings.sketch}
|
||||||
}`}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="drawio-ui"
|
id="drawio-ui"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={onToggleDrawioUi}
|
onClick={onToggleDrawioUi}
|
||||||
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
|
|
||||||
>
|
>
|
||||||
{dict.settings.switchTo}{" "}
|
{dict.settings.switchTo}{" "}
|
||||||
{drawioUi === "min"
|
{drawioUi === "min"
|
||||||
? dict.settings.sketch
|
? dict.settings.sketch
|
||||||
: dict.settings.minimal}
|
: dict.settings.minimal}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</div>
|
||||||
|
|
||||||
{/* Close Protection */}
|
<div className="flex items-center justify-between">
|
||||||
<SettingItem
|
<div className="space-y-0.5">
|
||||||
label={dict.settings.closeProtection}
|
<Label htmlFor="close-protection">
|
||||||
description={dict.settings.closeProtectionDescription}
|
{dict.settings.closeProtection}
|
||||||
>
|
</Label>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
{dict.settings.closeProtectionDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="close-protection"
|
id="close-protection"
|
||||||
checked={closeProtection}
|
checked={closeProtection}
|
||||||
@@ -344,13 +312,10 @@ function SettingsContent({
|
|||||||
onCloseProtectionChange?.(checked)
|
onCloseProtectionChange?.(checked)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-4 border-t border-border/50">
|
||||||
{/* Footer */}
|
<p className="text-[0.75rem] text-muted-foreground text-center">
|
||||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
Version {process.env.APP_VERSION}
|
Version {process.env.APP_VERSION}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,9 +328,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<DialogContent className="sm:max-w-lg p-0">
|
<DialogContent className="sm:max-w-md">
|
||||||
<div className="h-80 flex items-center justify-center">
|
<div className="h-64 flex items-center justify-center">
|
||||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
"duration-200",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,32 +57,13 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base styles
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
"fixed top-[50%] left-[50%] z-50 w-full",
|
|
||||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
|
||||||
"grid gap-4 p-6",
|
|
||||||
// Refined visual treatment
|
|
||||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
|
||||||
// Entry/exit animations
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
|
||||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
|
||||||
"duration-200 sm:max-w-lg",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className={cn(
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
"absolute top-4 right-4 rounded-xl p-1.5",
|
|
||||||
"text-muted-foreground/60 hover:text-foreground",
|
|
||||||
"hover:bg-interactive-hover",
|
|
||||||
"transition-all duration-150",
|
|
||||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
"disabled:pointer-events-none",
|
|
||||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
|
||||||
)}>
|
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -124,10 +102,7 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
"text-xl font-semibold tracking-tight leading-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -140,10 +115,7 @@ function DialogDescription({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
"text-sm text-muted-foreground leading-relaxed",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,30 +8,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base styles
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"border border-border-subtle bg-surface-1",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
"text-sm text-foreground",
|
|
||||||
// Placeholder
|
|
||||||
"placeholder:text-muted-foreground/60",
|
|
||||||
// Selection
|
|
||||||
"selection:bg-primary selection:text-primary-foreground",
|
|
||||||
// Transitions
|
|
||||||
"transition-all duration-150 ease-out",
|
|
||||||
// Hover state
|
|
||||||
"hover:border-border-default",
|
|
||||||
// Focus state - refined ring
|
|
||||||
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
|
||||||
// File input
|
|
||||||
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
|
||||||
"file:bg-transparent file:text-sm file:font-medium",
|
|
||||||
// Disabled
|
|
||||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
// Invalid state
|
|
||||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
|
||||||
"dark:aria-invalid:ring-destructive/40",
|
|
||||||
// Dark mode background
|
|
||||||
"dark:bg-surface-1",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -588,15 +588,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "openai": {
|
case "openai": {
|
||||||
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
const apiKey = overrides?.apiKey || process.env.OPENAI_API_KEY
|
||||||
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
const baseURL = overrides?.baseUrl || process.env.OPENAI_BASE_URL
|
||||||
if (baseURL) {
|
if (baseURL || overrides?.apiKey) {
|
||||||
// Custom base URL = third-party proxy, use Chat Completions API
|
const customOpenAI = createOpenAI({
|
||||||
// for compatibility (most proxies don't support /responses endpoint)
|
apiKey,
|
||||||
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
...(baseURL && { baseURL }),
|
||||||
model = customOpenAI.chat(modelId)
|
})
|
||||||
} else if (overrides?.apiKey) {
|
// Use Responses API (default) instead of .chat() to support reasoning
|
||||||
// Custom API key but official OpenAI endpoint, use Responses API
|
// for gpt-5, o1, o3, o4 models. Chat Completions API does not emit reasoning events.
|
||||||
// to support reasoning for gpt-5, o1, o3, o4 models
|
|
||||||
const customOpenAI = createOpenAI({ apiKey })
|
|
||||||
model = customOpenAI(modelId)
|
model = customOpenAI(modelId)
|
||||||
} else {
|
} else {
|
||||||
model = openai(modelId)
|
model = openai(modelId)
|
||||||
|
|||||||
@@ -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}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"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 { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
|
||||||
export interface QuotaConfig {
|
export interface QuotaConfig {
|
||||||
dailyRequestLimit: number
|
dailyRequestLimit: number
|
||||||
@@ -12,45 +13,181 @@ 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): {
|
||||||
showQuotaLimitToast: (used?: number, limit?: number) => void
|
hasOwnApiKey: () => boolean
|
||||||
showTokenLimitToast: (used?: number, limit?: number) => void
|
checkDailyLimit: () => QuotaCheckResult
|
||||||
showTPMLimitToast: (limit?: number) => void
|
checkTokenLimit: () => QuotaCheckResult
|
||||||
|
checkTPMLimit: () => QuotaCheckResult
|
||||||
|
incrementRequestCount: () => void
|
||||||
|
incrementTokenCount: (tokens: number) => void
|
||||||
|
incrementTPMCount: (tokens: number) => void
|
||||||
|
showQuotaLimitToast: () => void
|
||||||
|
showTokenLimitToast: (used: number) => void
|
||||||
|
showTPMLimitToast: () => void
|
||||||
} {
|
} {
|
||||||
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
|
const { dailyRequestLimit, dailyTokenLimit, tpmLimit } = config
|
||||||
|
|
||||||
const dict = useDictionary()
|
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(() => {
|
||||||
(used?: number, limit?: number) => {
|
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<QuotaLimitToast
|
<QuotaLimitToast
|
||||||
used={used ?? dailyRequestLimit}
|
used={dailyRequestLimit}
|
||||||
limit={limit ?? dailyRequestLimit}
|
limit={dailyRequestLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
onDismiss={() => toast.dismiss(t)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{ duration: 15000 },
|
{ duration: 15000 },
|
||||||
)
|
)
|
||||||
},
|
}, [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, limit?: number) => {
|
(used: number) => {
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<QuotaLimitToast
|
<QuotaLimitToast
|
||||||
type="token"
|
type="token"
|
||||||
used={used ?? dailyTokenLimit}
|
used={used}
|
||||||
limit={limit ?? dailyTokenLimit}
|
limit={dailyTokenLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
onDismiss={() => toast.dismiss(t)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -60,24 +197,54 @@ 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(() => {
|
||||||
(limit?: number) => {
|
|
||||||
const effectiveLimit = limit ?? tpmLimit
|
|
||||||
const limitDisplay =
|
const limitDisplay =
|
||||||
effectiveLimit >= 1000
|
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
|
||||||
? `${effectiveLimit / 1000}k`
|
|
||||||
: String(effectiveLimit)
|
|
||||||
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
||||||
limit: limitDisplay,
|
limit: limitDisplay,
|
||||||
seconds: 60,
|
seconds: 60,
|
||||||
})
|
})
|
||||||
toast.error(message, { duration: 8000 })
|
toast.error(message, { duration: 8000 })
|
||||||
},
|
}, [tpmLimit, dict])
|
||||||
[tpmLimit, dict],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Check functions
|
||||||
|
hasOwnApiKey,
|
||||||
|
checkDailyLimit,
|
||||||
|
checkTokenLimit,
|
||||||
|
checkTPMLimit,
|
||||||
|
|
||||||
|
// Increment functions
|
||||||
|
incrementRequestCount,
|
||||||
|
incrementTokenCount,
|
||||||
|
incrementTPMCount,
|
||||||
|
|
||||||
|
// Toast functions
|
||||||
showQuotaLimitToast,
|
showQuotaLimitToast,
|
||||||
showTokenLimitToast,
|
showTokenLimitToast,
|
||||||
showTPMLimitToast,
|
showTPMLimitToast,
|
||||||
|
|||||||
41
lib/utils.ts
41
lib/utils.ts
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
1374
package-lock.json
generated
1374
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user