Compare commits
34 Commits
fix/electr
...
63398d9f34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63398d9f34 | ||
|
|
82f4deb23a | ||
|
|
1fab261cd0 | ||
|
|
7a4a04c263 | ||
|
|
0d2e7a7ad6 | ||
|
|
3218ccc909 | ||
|
|
d3be96de79 | ||
|
|
b2dfd5b890 | ||
|
|
72d647de7a | ||
|
|
c6b0e5ac62 | ||
|
|
7de192e1fa | ||
|
|
97ae9395cd | ||
|
|
5ec05eb100 | ||
|
|
9aec7eda79 | ||
|
|
a0fbc0ad33 | ||
|
|
0385c45a10 | ||
|
|
5262b7bfb2 | ||
|
|
8cb7494d16 | ||
|
|
98625dd72a | ||
|
|
b5734aa5e1 | ||
|
|
87cdc53665 | ||
|
|
b4fc259de8 | ||
|
|
28f9a81e7b | ||
|
|
0f67884ead | ||
|
|
3521495ead | ||
|
|
6446454cd7 | ||
|
|
84959637db | ||
|
|
9e9ea10beb | ||
|
|
deae5c2c38 | ||
|
|
6e2d98e52d | ||
|
|
85cb441e26 | ||
|
|
b088a0653e | ||
|
|
b25b944600 | ||
|
|
4f07a5fafc |
2
.github/workflows/docker-build.yml
vendored
@@ -63,6 +63,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=true
|
||||||
|
|
||||||
# Push to AWS ECR for App Runner auto-deploy
|
# Push to AWS ECR for App Runner auto-deploy
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
|
|||||||
60
.github/workflows/electron-release.yml
vendored
@@ -12,6 +12,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -38,63 +40,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Electron app
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
|
||||||
if: matrix.platform == 'mac'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: mac-build
|
|
||||||
path: |
|
|
||||||
release/*.dmg
|
|
||||||
release/*.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
|
||||||
if: matrix.platform == 'win'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: win-build
|
|
||||||
path: |
|
|
||||||
release/*.exe
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
|
||||||
if: matrix.platform == 'linux'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: linux-build
|
|
||||||
path: |
|
|
||||||
release/*.AppImage
|
|
||||||
release/*.deb
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
artifacts/**/*.dmg
|
|
||||||
artifacts/**/*.zip
|
|
||||||
artifacts/**/*.exe
|
|
||||||
artifacts/**/*.AppImage
|
|
||||||
artifacts/**/*.deb
|
|
||||||
draft: true
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||||
|
|
||||||
|
# Build-time argument to show About link and Notice icon
|
||||||
|
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||||
|
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||||
|
|
||||||
# Build Next.js application (standalone mode)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ export default function AboutCN() {
|
|||||||
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
(TPS/TPM)。一旦超限,系统就会暂停,导致请求失败。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
由于使用量过高,我已将模型从 Claude 更换为{" "}
|
由于使用量过高,我已将模型从 Opus 4.5 更换为{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
minimax-m2
|
Haiku 4.5
|
||||||
</span>
|
</span>
|
||||||
,以降低成本。
|
,以降低成本。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
利用量の増加に伴い、コスト削減のためモデルを
|
利用量の増加に伴い、コスト削減のためモデルを
|
||||||
Claude から{" "}
|
Opus 4.5 から{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
minimax-m2
|
Haiku 4.5
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
に変更しました。
|
に変更しました。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Due to the high usage, I have changed the
|
Due to the high usage, I have changed the
|
||||||
model from Claude to{" "}
|
model from Opus 4.5 to{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
minimax-m2
|
Haiku 4.5
|
||||||
</span>
|
</span>
|
||||||
, which is more cost-effective.
|
, which is more cost-effective.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ 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,
|
||||||
@@ -162,9 +167,13 @@ 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
|
// Get user IP for Langfuse tracking (hashed for privacy)
|
||||||
const forwardedFor = req.headers.get("x-forwarded-for")
|
const forwardedFor = req.headers.get("x-forwarded-for")
|
||||||
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||||
|
const userId =
|
||||||
|
rawIp === "anonymous"
|
||||||
|
? rawIp
|
||||||
|
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
|
||||||
|
|
||||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
||||||
const validSessionId =
|
const validSessionId =
|
||||||
@@ -173,9 +182,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Extract user input text for Langfuse trace
|
// Extract user input text for Langfuse trace
|
||||||
const lastMessage = messages[messages.length - 1]
|
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||||
|
const lastUserMessage = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m: any) => m.role === "user")
|
||||||
const userInputText =
|
const userInputText =
|
||||||
lastMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
lastUserMessage?.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({
|
||||||
@@ -184,6 +196,33 @@ 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) {
|
||||||
@@ -214,6 +253,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
baseUrl: req.headers.get("x-ai-base-url"),
|
baseUrl: req.headers.get("x-ai-base-url"),
|
||||||
apiKey: req.headers.get("x-ai-api-key"),
|
apiKey: req.headers.get("x-ai-api-key"),
|
||||||
modelId: req.headers.get("x-ai-model"),
|
modelId: req.headers.get("x-ai-model"),
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||||
|
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||||
|
awsRegion: req.headers.get("x-aws-region"),
|
||||||
|
awsSessionToken: req.headers.get("x-aws-session-token"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read minimal style preference from header
|
// Read minimal style preference from header
|
||||||
@@ -232,9 +276,10 @@ 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 message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts =
|
||||||
lastMessage.parts?.filter((part: any) => part.type === "file") || []
|
lastUserMessage?.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:
|
||||||
@@ -243,7 +288,7 @@ ${userInputText}
|
|||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message
|
// Convert UIMessages to ModelMessages and add system message
|
||||||
const modelMessages = convertToModelMessages(messages)
|
const modelMessages = await 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)
|
||||||
@@ -497,12 +542,26 @@ ${userInputText}
|
|||||||
userId,
|
userId,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
onFinish: ({ text, usage }) => {
|
onFinish: ({ text, totalUsage }) => {
|
||||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||||
setTraceOutput(text, {
|
setTraceOutput(text)
|
||||||
promptTokens: usage?.inputTokens,
|
|
||||||
completionTokens: usage?.outputTokens,
|
// Record token usage for server-side quota tracking (if enabled)
|
||||||
})
|
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||||
|
// Include all 4 token types: input, output, cache read, cache write
|
||||||
|
if (
|
||||||
|
isQuotaEnabled() &&
|
||||||
|
!hasOwnApiKey &&
|
||||||
|
userId !== "anonymous" &&
|
||||||
|
totalUsage
|
||||||
|
) {
|
||||||
|
const totalTokens =
|
||||||
|
(totalUsage.inputTokens || 0) +
|
||||||
|
(totalUsage.outputTokens || 0) +
|
||||||
|
(totalUsage.cachedInputTokens || 0) +
|
||||||
|
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
||||||
|
recordTokenUsage(userId, totalTokens)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
@@ -672,19 +731,9 @@ 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
|
||||||
if (!usage) {
|
// AI SDK 6 provides totalTokens directly
|
||||||
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 {
|
||||||
inputTokens: totalInputTokens,
|
totalTokens: usage?.totalTokens ?? 0,
|
||||||
outputTokens: usage.outputTokens ?? 0,
|
|
||||||
finishReason: (part as any).finishReason,
|
finishReason: (part as any).finishReason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const { messageId, feedback, sessionId } = data
|
const { messageId, feedback, sessionId } = data
|
||||||
|
|
||||||
// Get user IP for tracking
|
// Skip logging if no sessionId - prevents attaching to wrong user's trace
|
||||||
|
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 userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
|
||||||
|
const userId =
|
||||||
|
rawIp === "anonymous"
|
||||||
|
? rawIp
|
||||||
|
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Find the most recent chat trace for this session to attach the score to
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ 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()
|
||||||
|
|
||||||
|
|||||||
281
app/api/validate-model/route.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||||
|
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||||
|
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||||
|
import { createGateway } from "@ai-sdk/gateway"
|
||||||
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
|
import { createOpenAI } from "@ai-sdk/openai"
|
||||||
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
|
import { generateText } from "ai"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: Check if URL points to private/internal network (SSRF protection)
|
||||||
|
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||||
|
*/
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
// 10.0.0.0/8
|
||||||
|
if (a === 10) return true
|
||||||
|
// 172.16.0.0/12
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true
|
||||||
|
// 192.168.0.0/16
|
||||||
|
if (a === 192 && b === 168) return true
|
||||||
|
// 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 169 && b === 254) return true
|
||||||
|
// 127.0.0.0/8 (loopback)
|
||||||
|
if (a === 127) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Invalid URL - block it
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidateRequest {
|
||||||
|
provider: string
|
||||||
|
apiKey: string
|
||||||
|
baseUrl?: string
|
||||||
|
modelId: string
|
||||||
|
// AWS Bedrock specific
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body: ValidateRequest = await req.json()
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
modelId,
|
||||||
|
awsAccessKeyId,
|
||||||
|
awsSecretAccessKey,
|
||||||
|
awsRegion,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!provider || !modelId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Provider and model ID are required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||||
|
if (baseUrl && isPrivateUrl(baseUrl)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "Invalid base URL" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials based on provider
|
||||||
|
if (provider === "bedrock") {
|
||||||
|
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: "AWS credentials (Access Key ID, Secret Access Key, Region) are required",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (provider !== "ollama" && !apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: "API key is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let model: any
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case "openai": {
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = openai.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "anthropic": {
|
||||||
|
const anthropic = createAnthropic({
|
||||||
|
apiKey,
|
||||||
|
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||||
|
})
|
||||||
|
model = anthropic(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "google": {
|
||||||
|
const google = createGoogleGenerativeAI({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = google(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "azure": {
|
||||||
|
const azure = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: baseUrl,
|
||||||
|
})
|
||||||
|
model = azure.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "bedrock": {
|
||||||
|
const bedrock = createAmazonBedrock({
|
||||||
|
accessKeyId: awsAccessKeyId,
|
||||||
|
secretAccessKey: awsSecretAccessKey,
|
||||||
|
region: awsRegion,
|
||||||
|
})
|
||||||
|
model = bedrock(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "openrouter": {
|
||||||
|
const openrouter = createOpenRouter({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = openrouter(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "deepseek": {
|
||||||
|
if (baseUrl || apiKey) {
|
||||||
|
const ds = createDeepSeek({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = ds(modelId)
|
||||||
|
} else {
|
||||||
|
model = deepseek(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "siliconflow": {
|
||||||
|
const sf = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: baseUrl || "https://api.siliconflow.com/v1",
|
||||||
|
})
|
||||||
|
model = sf.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ollama": {
|
||||||
|
const ollama = createOllama({
|
||||||
|
baseURL: baseUrl || "http://localhost:11434",
|
||||||
|
})
|
||||||
|
model = ollama(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "gateway": {
|
||||||
|
const gw = createGateway({
|
||||||
|
apiKey,
|
||||||
|
...(baseUrl && { baseURL: baseUrl }),
|
||||||
|
})
|
||||||
|
model = gw(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a minimal test request
|
||||||
|
const startTime = Date.now()
|
||||||
|
await generateText({
|
||||||
|
model,
|
||||||
|
prompt: "Say 'OK'",
|
||||||
|
maxOutputTokens: 20,
|
||||||
|
})
|
||||||
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
responseTime,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[validate-model] Error:", error)
|
||||||
|
|
||||||
|
let errorMessage = "Validation failed"
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Extract meaningful error message
|
||||||
|
if (
|
||||||
|
error.message.includes("401") ||
|
||||||
|
error.message.includes("Unauthorized")
|
||||||
|
) {
|
||||||
|
errorMessage = "Invalid API key"
|
||||||
|
} else if (
|
||||||
|
error.message.includes("404") ||
|
||||||
|
error.message.includes("not found")
|
||||||
|
) {
|
||||||
|
errorMessage = "Model not found"
|
||||||
|
} else if (
|
||||||
|
error.message.includes("429") ||
|
||||||
|
error.message.includes("rate limit")
|
||||||
|
) {
|
||||||
|
errorMessage = "Rate limited - try again later"
|
||||||
|
} else if (error.message.includes("ECONNREFUSED")) {
|
||||||
|
errorMessage = "Cannot connect to server"
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message.slice(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ valid: false, error: errorMessage },
|
||||||
|
{ status: 200 }, // Return 200 so client can read error message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/globals.css
@@ -144,6 +144,68 @@
|
|||||||
--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;
|
||||||
@@ -257,3 +319,83 @@
|
|||||||
-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import type { MetadataRoute } from "next"
|
import type { MetadataRoute } from "next"
|
||||||
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Next AI Draw.io",
|
name: "Next AI Draw.io",
|
||||||
short_name: "AIDraw.io",
|
short_name: "AIDraw.io",
|
||||||
description:
|
description:
|
||||||
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
"Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
start_url: "/",
|
start_url: getAssetUrl("/"),
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#f9fafb",
|
background_color: "#f9fafb",
|
||||||
theme_color: "#171d26",
|
theme_color: "#171d26",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/favicon-192x192.png",
|
src: getAssetUrl("/favicon-192x192.png"),
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/favicon-512x512.png",
|
src: getAssetUrl("/favicon-512x512.png"),
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
purpose: "any",
|
purpose: "any",
|
||||||
|
|||||||
156
components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Cloud } from "lucide-react"
|
||||||
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
||||||
|
|
||||||
|
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||||
|
<Dialog {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
||||||
|
|
||||||
|
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||||
|
<DialogTrigger {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||||
|
title?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorContent = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
title = "Model Selector",
|
||||||
|
...props
|
||||||
|
}: ModelSelectorContentProps) => (
|
||||||
|
<DialogContent className={cn("p-0", className)} {...props}>
|
||||||
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
||||||
|
|
||||||
|
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||||
|
<CommandDialog {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
||||||
|
|
||||||
|
export const ModelSelectorInput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorInputProps) => (
|
||||||
|
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
||||||
|
|
||||||
|
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||||
|
<CommandList {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
||||||
|
|
||||||
|
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||||
|
<CommandEmpty {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
||||||
|
|
||||||
|
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||||
|
<CommandGroup {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
||||||
|
|
||||||
|
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||||
|
<CommandItem {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
||||||
|
|
||||||
|
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||||
|
<CommandShortcut {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||||
|
typeof CommandSeparator
|
||||||
|
>
|
||||||
|
|
||||||
|
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||||
|
<CommandSeparator {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorLogoProps = Omit<
|
||||||
|
ComponentProps<"img">,
|
||||||
|
"src" | "alt"
|
||||||
|
> & {
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelSelectorLogo = ({
|
||||||
|
provider,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorLogoProps) => {
|
||||||
|
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
||||||
|
if (provider === "amazon-bedrock") {
|
||||||
|
return <Cloud className={cn("size-4", className)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
alt={`${provider} logo`}
|
||||||
|
className={cn("size-4 dark:invert", className)}
|
||||||
|
height={16}
|
||||||
|
src={`https://models.dev/logos/${provider}.svg`}
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
||||||
|
|
||||||
|
export const ModelSelectorLogoGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorLogoGroupProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ModelSelectorNameProps = ComponentProps<"span">
|
||||||
|
|
||||||
|
export const ModelSelectorName = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ModelSelectorNameProps) => (
|
||||||
|
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
|
|
||||||
interface ExampleCardProps {
|
interface ExampleCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
@@ -79,7 +80,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this flowchart.")
|
setInput("Replicate this flowchart.")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/example.png")
|
const response = await fetch(getAssetUrl("/example.png"))
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "example.png", { type: "image/png" })
|
const file = new File([blob], "example.png", { type: "image/png" })
|
||||||
setFiles([file])
|
setFiles([file])
|
||||||
@@ -92,7 +93,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Replicate this in aws style")
|
setInput("Replicate this in aws style")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/architecture.png")
|
const response = await fetch(getAssetUrl("/architecture.png"))
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "architecture.png", {
|
const file = new File([blob], "architecture.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
@@ -107,7 +108,7 @@ export default function ExamplePanel({
|
|||||||
setInput("Summarize this paper as a diagram")
|
setInput("Summarize this paper as a diagram")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/chain-of-thought.txt")
|
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], "chain-of-thought.txt", {
|
const file = new File([blob], "chain-of-thought.txt", {
|
||||||
type: "text/plain",
|
type: "text/plain",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from "sonner"
|
|||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -28,6 +29,7 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
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 { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -156,6 +158,11 @@ interface ChatInputProps {
|
|||||||
error?: Error | null
|
error?: Error | null
|
||||||
minimalStyle?: boolean
|
minimalStyle?: boolean
|
||||||
onMinimalStyleChange?: (value: boolean) => void
|
onMinimalStyleChange?: (value: boolean) => void
|
||||||
|
// Model selector props
|
||||||
|
models?: FlattenedModel[]
|
||||||
|
selectedModelId?: string
|
||||||
|
onModelSelect?: (modelId: string | undefined) => void
|
||||||
|
onConfigureModels?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -173,6 +180,10 @@ export function ChatInput({
|
|||||||
error = null,
|
error = null,
|
||||||
minimalStyle = false,
|
minimalStyle = false,
|
||||||
onMinimalStyleChange = () => {},
|
onMinimalStyleChange = () => {},
|
||||||
|
models = [],
|
||||||
|
selectedModelId,
|
||||||
|
onModelSelect = () => {},
|
||||||
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const {
|
const {
|
||||||
@@ -465,6 +476,14 @@ export function ChatInput({
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
selectedModelId={selectedModelId}
|
||||||
|
onSelect={onModelSelect}
|
||||||
|
onConfigure={onConfigureModels}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import {
|
import {
|
||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
|
extractCompleteMxCells,
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
@@ -291,7 +293,7 @@ export function ChatMessageDisplay({
|
|||||||
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
setFeedback((prev) => ({ ...prev, [messageId]: value }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/log-feedback", {
|
await fetch(getApiEndpoint("/api/log-feedback"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -314,12 +316,28 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
const currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
|
if (!showToast) {
|
||||||
|
const completeCells = extractCompleteMxCells(currentXml)
|
||||||
|
if (!completeCells) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentXml = completeCells
|
||||||
|
}
|
||||||
|
|
||||||
const 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()
|
||||||
const testDoc = parser.parseFromString(convertedXml, "text/xml")
|
// Wrap in root element for parsing multiple mxCell elements
|
||||||
|
const testDoc = parser.parseFromString(
|
||||||
|
`<root>${convertedXml}</root>`,
|
||||||
|
"text/xml",
|
||||||
|
)
|
||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
@@ -346,7 +364,22 @@ 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)
|
||||||
|
|
||||||
// Validate and auto-fix the XML
|
const xmlProcessTime = performance.now() - startTime
|
||||||
|
|
||||||
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
|
// The quick DOM parse check above catches malformed XML
|
||||||
|
// Full validation runs on final output (showToast=true)
|
||||||
|
if (!showToast) {
|
||||||
|
previousXML.current = convertedXml
|
||||||
|
const loadStartTime = performance.now()
|
||||||
|
onDisplayChart(replacedXML, true)
|
||||||
|
console.log(
|
||||||
|
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final output: run full validation and auto-fix
|
||||||
const validation = validateAndFixXml(replacedXML)
|
const validation = validateAndFixXml(replacedXML)
|
||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
@@ -359,18 +392,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)
|
toast.error(
|
||||||
if (showToast) {
|
"Diagram validation failed. Please try regenerating.",
|
||||||
toast.error(
|
)
|
||||||
"Diagram validation failed. Please try regenerating.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -602,17 +636,10 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup: clear any pending debounce timeout on unmount
|
// NOTE: Don't cleanup debounce timeouts here!
|
||||||
return () => {
|
// The cleanup runs on every re-render (when messages changes),
|
||||||
if (debounceTimeoutRef.current) {
|
// which would cancel the timeout before it fires.
|
||||||
clearTimeout(debounceTimeoutRef.current)
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
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) => {
|
||||||
|
|||||||
@@ -18,18 +18,25 @@ import { FaGithub } from "react-icons/fa"
|
|||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getAIConfig } from "@/lib/ai-config"
|
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||||
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
import { formatXML, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
import LanguageToggle from "./language-toggle"
|
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||||
|
|
||||||
// localStorage keys for persistence
|
// localStorage keys for persistence
|
||||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
||||||
@@ -71,6 +78,8 @@ 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.
|
||||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||||
@@ -146,7 +155,10 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||||
const [, setAccessCodeRequired] = useState(false)
|
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
||||||
|
|
||||||
|
// Model configuration hook
|
||||||
|
const modelConfig = useModelConfig()
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
@@ -164,15 +176,14 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/config")
|
fetch(getApiEndpoint("/api/config"))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setAccessCodeRequired(data.accessCodeRequired)
|
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
setTpmLimit(data.tpmLimit || 0)
|
||||||
})
|
})
|
||||||
.catch(() => setAccessCodeRequired(false))
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Quota management using extracted hook
|
// Quota management using extracted hook
|
||||||
@@ -208,6 +219,8 @@ 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
|
||||||
@@ -236,7 +249,7 @@ export default function ChatPanel({
|
|||||||
setMessages,
|
setMessages,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: "/api/chat",
|
api: getApiEndpoint("/api/chat"),
|
||||||
}),
|
}),
|
||||||
async onToolCall({ toolCall }) {
|
async onToolCall({ toolCall }) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@@ -545,6 +558,43 @@ 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)
|
||||||
@@ -609,8 +659,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
// Show settings button and open dialog to help user fix it
|
// Show settings dialog to help user fix it
|
||||||
setAccessCodeRequired(true)
|
|
||||||
setShowSettingsDialog(true)
|
setShowSettingsDialog(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -622,22 +671,6 @@ 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
|
||||||
@@ -649,15 +682,25 @@ 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: unlimited retries (truncation continuation, not real errors)
|
// Continuation mode: limited retries for truncation handling
|
||||||
// Server limits to 5 steps via stepCountIs(5)
|
|
||||||
if (isInContinuationMode) {
|
if (isInContinuationMode) {
|
||||||
// Don't count against retry limit for continuation
|
if (
|
||||||
// Quota checks still apply below
|
continuationRetryCountRef.current >=
|
||||||
|
MAX_CONTINUATION_RETRY_COUNT
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`,
|
||||||
|
)
|
||||||
|
continuationRetryCountRef.current = 0
|
||||||
|
partialXmlRef.current = ""
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continuationRetryCountRef.current++
|
||||||
} 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) {
|
||||||
@@ -672,23 +715,6 @@ 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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -905,9 +931,6 @@ 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
|
||||||
@@ -985,30 +1008,7 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
saveXmlSnapshots()
|
saveXmlSnapshots()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all quota limits (daily requests, tokens, TPM)
|
// Send chat message with headers
|
||||||
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,
|
||||||
@@ -1017,9 +1017,10 @@ 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 = getAIConfig()
|
const config = getSelectedAIConfig()
|
||||||
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
@@ -1036,6 +1037,20 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
"x-ai-api-key": config.aiApiKey,
|
"x-ai-api-key": config.aiApiKey,
|
||||||
}),
|
}),
|
||||||
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
...(config.aiModel && { "x-ai-model": config.aiModel }),
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
...(config.awsAccessKeyId && {
|
||||||
|
"x-aws-access-key-id": config.awsAccessKeyId,
|
||||||
|
}),
|
||||||
|
...(config.awsSecretAccessKey && {
|
||||||
|
"x-aws-secret-access-key":
|
||||||
|
config.awsSecretAccessKey,
|
||||||
|
}),
|
||||||
|
...(config.awsRegion && {
|
||||||
|
"x-aws-region": config.awsRegion,
|
||||||
|
}),
|
||||||
|
...(config.awsSessionToken && {
|
||||||
|
"x-aws-session-token": config.awsSessionToken,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
...(minimalStyle && {
|
...(minimalStyle && {
|
||||||
"x-minimal-style": "true",
|
"x-minimal-style": "true",
|
||||||
@@ -1043,7 +1058,6 @@ 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)
|
||||||
@@ -1131,13 +1145,8 @@ 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) => {
|
||||||
@@ -1179,12 +1188,8 @@ 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)
|
||||||
@@ -1248,32 +1253,18 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
Next AI Drawio
|
Next AI Drawio
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
{!isMobile &&
|
||||||
<Link
|
process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
||||||
href="/about"
|
"true" && (
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noopener noreferrer"
|
href="/about"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
About
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{!isMobile && (
|
|
||||||
<Link
|
|
||||||
href="/about"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<ButtonWithTooltip
|
|
||||||
tooltipContent="Due to high usage, I have changed the model to minimax-m2 and added some usage limits. See About page for details."
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-amber-500 hover:text-amber-600"
|
|
||||||
>
|
>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
About
|
||||||
</ButtonWithTooltip>
|
</Link>
|
||||||
</Link>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 justify-end overflow-visible">
|
<div className="flex items-center gap-1 justify-end overflow-visible">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -1288,16 +1279,23 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
/>
|
/>
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
<Tooltip>
|
||||||
target="_blank"
|
<TooltipTrigger asChild>
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
>
|
target="_blank"
|
||||||
<FaGithub
|
rel="noopener noreferrer"
|
||||||
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
/>
|
>
|
||||||
</a>
|
<FaGithub
|
||||||
|
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{dict.nav.github}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.settings}
|
tooltipContent={dict.nav.settings}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1310,7 +1308,6 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
/>
|
/>
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
<LanguageToggle />
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.hidePanel}
|
tooltipContent={dict.nav.hidePanel}
|
||||||
@@ -1342,6 +1339,14 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Dev XML Streaming Simulator - only in development */}
|
||||||
|
{DEBUG && (
|
||||||
|
<DevXmlSimulator
|
||||||
|
setMessages={setMessages}
|
||||||
|
onDisplayChart={onDisplayChart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<footer
|
<footer
|
||||||
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
|
||||||
@@ -1361,6 +1366,10 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
error={error}
|
error={error}
|
||||||
minimalStyle={minimalStyle}
|
minimalStyle={minimalStyle}
|
||||||
onMinimalStyleChange={setMinimalStyle}
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
|
models={modelConfig.models}
|
||||||
|
selectedModelId={modelConfig.selectedModelId}
|
||||||
|
onModelSelect={modelConfig.setSelectedModelId}
|
||||||
|
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1374,6 +1383,12 @@ Continue from EXACTLY where you stopped.`,
|
|||||||
onToggleDarkMode={onToggleDarkMode}
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelConfigDialog
|
||||||
|
open={showModelConfigDialog}
|
||||||
|
onOpenChange={setShowModelConfigDialog}
|
||||||
|
modelConfig={modelConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResetWarningModal
|
<ResetWarningModal
|
||||||
open={showNewChatDialog}
|
open={showNewChatDialog}
|
||||||
onOpenChange={setShowNewChatDialog}
|
onOpenChange={setShowNewChatDialog}
|
||||||
|
|||||||
350
components/dev-xml-simulator.tsx
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Dev XML presets for streaming simulator
|
||||||
|
const DEV_XML_PRESETS: Record<string, string> = {
|
||||||
|
"Simple Box": `<mxCell id="2" value="Hello World" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`,
|
||||||
|
"Two Boxes with Arrow": `<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="100" height="50" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="100" width="100" height="50" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="4" value="" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>`,
|
||||||
|
Flowchart: `<mxCell id="2" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="160" y="40" width="80" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3" value="Process A" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="140" y="120" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="4" value="Decision" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="150" y="220" width="100" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="5" value="Process B" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="300" y="230" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="6" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="160" y="340" width="80" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="7" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="3">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="8" style="endArrow=classic;html=1;" edge="1" parent="1" source="3" target="4">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="9" value="Yes" style="endArrow=classic;html=1;" edge="1" parent="1" source="4" target="6">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="10" value="No" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="4" target="5">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>`,
|
||||||
|
"Truncated (Error Test)": `<mxCell id="2" value="This cell is truncated" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="120" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3" value="Incomplete" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor`,
|
||||||
|
"HTML Escape + Cell Truncate": `<mxCell id="2" value="<b>Chain-of-Thought Prompting</b><br/><font size='12'>Eliciting Reasoning in Large Language Models</font>" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=16;fontStyle=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="40" width="720" height="60" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="3" value="<b>Problem: LLM Reasoning Limitations</b><br/>• Scaling parameters alone insufficient for logical tasks<br/>• Arithmetic, commonsense, symbolic reasoning challenges<br/>• Standard prompting fails on multi-step problems" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="120" width="340" height="120" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="4" value="<b>Traditional Approaches</b><br/>1. <b>Finetuning:</b> Expensive, task-specific<br/>2. <b>Standard Few-Shot:</b> Input→Output pairs<br/> (No explanation of reasoning)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="420" y="120" width="340" height="120" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="5" value="<b>CoT Methodology</b><br/>• Add reasoning steps to few-shot examples<br/>• Natural language intermediate steps<br/>• No parameter updates needed<br/>• Model learns to generate own thought process" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="260" width="340" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="6" value="<b>Example Comparison</b><br/><b>Standard:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: 11.<br/><br/><b>CoT:</b><br/>Q: Roger has 5 balls. He buys 2 cans of 3 balls. How many?<br/>A: Roger started with 5 balls. 2 cans of 3 tennis balls each is 6 tennis balls. 5 + 6 = 11. The answer is 11." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="420" y="260" width="340" height="140" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="7" value="<b>Experimental Models</b><br/>• GPT-3 (175B)<br/>• LaMDA (137B)<br/>• PaLM (540B)<br/>• UL2 (20B)<br/>• Codex" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="380" width="340" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="8" value="<b>Reasoning Domains Tested</b><br/>1. <b>Arithmetic:</b> GSM8K, SVAMP, ASDiv, AQuA, MAWPS<br/>2. <b>Commonsense:</b> CSQA, StrategyQA, Date Understanding, Sports Understanding<br/>3. <b>Symbolic:</b> Last Letter Concatenation, Coin Flip" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="420" y="420" width="340" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="9" value="<b>Key Results: Arithmetic</b><br/>• PaLM 540B + CoT: <b>56.9%</b> on GSM8K<br/> (vs 17.9% standard)<br/>• Surpassed finetuned GPT-3 (55%)<br/>• With calculator: <b>58.6%</b>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="500" width="220" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="10" value="<b>Key Results: Commonsense</b><br/>• StrategyQA: <b>75.6%</b><br/> (vs 69.4% SOTA)<br/>• Sports Understanding: <b>95.4%</b><br/> (vs 84% human)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="280" y="500" width="220" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="11" value="<b>Key Results: Symbolic</b><br/>• OOD Generalization<br/>• Coin Flip: Trained on 2 flips<br/> Works on 3-4 flips with CoT<br/>• Standard prompting fails" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="540" y="500" width="220" height="100" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="12" value="<b>Emergent Ability of Scale</b><br/>• Small models (<10B): No benefit, often harmful<br/>• Large models (100B+): Reasoning emerges<br/>• CoT gains increase dramatically with scale" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="620" width="340" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="13" value="<b>Ablation Studies</b><br/>1. Equation only: Worse than CoT<br/>2. Variable compute (...): No improvement<br/>3. Answer first, then reasoning: Same as baseline<br/>→ Content matters, not just extra tokens" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="420" y="620" width="340" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="14" value="<b>Error Analysis</b><br/>• Semantic understanding errors<br/>• One-step missing errors<br/>• Calculation errors<br/>• Larger models reduce semantic/missing-step errors" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="40" y="720" width="340" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="15" value="<b>Conclusion</b><br/>• CoT unlocks reasoning potential<br/>• Simple paradigm: "show your work"<br/>• Emergent capability of large models<br/>• No specialized architecture needed" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="420" y="720" width="340" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="3" target="5">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="4" target="6">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="18" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="5" target="7">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="6" target="8">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;" edge="1" parent="1" source="7" target="9">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="7" target="10">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.75;entryY=0;" edge="1" parent="1" source="7" target="11">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="9" target="12">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="10" target="13">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="11" target="14">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="12" target="15">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="13" target="15">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="14" target="15">
|
||||||
|
<mxGeometry relative="1" as="geometry"/>
|
||||||
|
</mxCell>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevXmlSimulatorProps {
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||||
|
onDisplayChart: (xml: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevXmlSimulator({
|
||||||
|
setMessages,
|
||||||
|
onDisplayChart,
|
||||||
|
}: DevXmlSimulatorProps) {
|
||||||
|
const [devXml, setDevXml] = useState("")
|
||||||
|
const [isSimulating, setIsSimulating] = useState(false)
|
||||||
|
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||||
|
const [devChunkSize, setDevChunkSize] = useState(10)
|
||||||
|
const devStopRef = useRef(false)
|
||||||
|
const devXmlInitializedRef = useRef(false)
|
||||||
|
|
||||||
|
// Restore dev XML from localStorage on mount (after hydration)
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("dev-xml-simulator")
|
||||||
|
if (saved) setDevXml(saved)
|
||||||
|
devXmlInitializedRef.current = true
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save dev XML to localStorage (only after initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (devXmlInitializedRef.current) {
|
||||||
|
localStorage.setItem("dev-xml-simulator", devXml)
|
||||||
|
}
|
||||||
|
}, [devXml])
|
||||||
|
|
||||||
|
const handleDevSimulate = async () => {
|
||||||
|
if (!devXml.trim() || isSimulating) return
|
||||||
|
|
||||||
|
setIsSimulating(true)
|
||||||
|
devStopRef.current = false
|
||||||
|
const toolCallId = `dev-sim-${Date.now()}`
|
||||||
|
const xml = devXml.trim()
|
||||||
|
|
||||||
|
// Add user message and initial assistant message with empty XML
|
||||||
|
const userMsg = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: "user" as const,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: "[Dev] Simulating XML streaming",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const assistantMsg = {
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
role: "assistant" as const,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "tool-display_diagram" as const,
|
||||||
|
toolCallId,
|
||||||
|
state: "input-streaming" as const,
|
||||||
|
input: { xml: "" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
setMessages((prev) => [...prev, userMsg, assistantMsg] as any)
|
||||||
|
|
||||||
|
// Stream characters progressively
|
||||||
|
for (let i = 0; i < xml.length; i += devChunkSize) {
|
||||||
|
if (devStopRef.current) {
|
||||||
|
setIsSimulating(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = xml.slice(0, i + devChunkSize)
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
|
lastMsg.parts[0].input = { xml: chunk }
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, devIntervalMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devStopRef.current) {
|
||||||
|
setIsSimulating(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize: set state to output-available
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
|
lastMsg.parts[0].state = "output-available"
|
||||||
|
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||||
|
lastMsg.parts[0].input = { xml }
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Display the final diagram
|
||||||
|
const fullXml = wrapWithMxFile(xml)
|
||||||
|
onDisplayChart(fullXml)
|
||||||
|
|
||||||
|
setIsSimulating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||||
|
<details>
|
||||||
|
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||||
|
Dev: XML Streaming Simulator
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Preset:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
setDevXml(DEV_XML_PRESETS[e.target.value])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 text-xs p-1 border rounded bg-background"
|
||||||
|
defaultValue=""
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a preset...
|
||||||
|
</option>
|
||||||
|
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDevXml("")}
|
||||||
|
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={devXml}
|
||||||
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
|
placeholder="Paste mxCell XML here or select a preset..."
|
||||||
|
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Interval:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
step="1"
|
||||||
|
value={devIntervalMs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDevIntervalMs(Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="flex-1 h-1 accent-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground w-12">
|
||||||
|
{devIntervalMs}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
Chars:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={devChunkSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDevChunkSize(
|
||||||
|
Math.max(1, Number(e.target.value)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-14 text-xs p-1 border rounded bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDevSimulate}
|
||||||
|
disabled={isSimulating || !devXml.trim()}
|
||||||
|
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSimulating
|
||||||
|
? "Streaming..."
|
||||||
|
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
|
</button>
|
||||||
|
{isSimulating && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
devStopRef.current = true
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Globe } from "lucide-react"
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { Suspense, useEffect, useRef, useState } from "react"
|
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
|
||||||
en: "EN",
|
|
||||||
zh: "中文",
|
|
||||||
ja: "日本語",
|
|
||||||
}
|
|
||||||
|
|
||||||
function LanguageToggleInner({ className = "" }: { className?: string }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname() || "/"
|
|
||||||
const search = useSearchParams()
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [value, setValue] = useState<Locale>(i18n.defaultLocale)
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const seg = pathname.split("/").filter(Boolean)
|
|
||||||
const first = seg[0]
|
|
||||||
if (first && i18n.locales.includes(first as Locale))
|
|
||||||
setValue(first as Locale)
|
|
||||||
else setValue(i18n.defaultLocale)
|
|
||||||
}, [pathname])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onDoc(e: MouseEvent) {
|
|
||||||
if (!ref.current) return
|
|
||||||
if (!ref.current.contains(e.target as Node)) setOpen(false)
|
|
||||||
}
|
|
||||||
if (open) document.addEventListener("mousedown", onDoc)
|
|
||||||
return () => document.removeEventListener("mousedown", onDoc)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const changeLocale = (lang: string) => {
|
|
||||||
const parts = pathname.split("/")
|
|
||||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
|
||||||
parts[1] = lang
|
|
||||||
} else {
|
|
||||||
parts.splice(1, 0, lang)
|
|
||||||
}
|
|
||||||
const newPath = parts.join("/") || "/"
|
|
||||||
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
|
||||||
setOpen(false)
|
|
||||||
router.push(newPath + searchStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative inline-flex ${className}`} ref={ref}>
|
|
||||||
<button
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen((s) => !s)}
|
|
||||||
className="p-2 rounded-full hover:bg-accent/20 transition-colors text-muted-foreground"
|
|
||||||
aria-label="Change language"
|
|
||||||
>
|
|
||||||
<Globe className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-40 bg-popover dark:bg-popover text-popover-foreground rounded-xl shadow-md border border-border/30 overflow-hidden z-50">
|
|
||||||
<div className="grid gap-0 divide-y divide-border/30">
|
|
||||||
{i18n.locales.map((loc) => (
|
|
||||||
<button
|
|
||||||
key={loc}
|
|
||||||
onClick={() => changeLocale(loc)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-left hover:bg-accent/10 transition-colors ${value === loc ? "bg-accent/10 font-semibold" : ""}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1">
|
|
||||||
{LABELS[loc] ?? loc}
|
|
||||||
</span>
|
|
||||||
{value === loc && (
|
|
||||||
<span className="text-xs opacity-70">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LanguageToggle({
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<button
|
|
||||||
className="p-2 rounded-full text-muted-foreground opacity-50"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Globe className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LanguageToggleInner className={className} />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
1545
components/model-config-dialog.tsx
Normal file
222
components/model-selector.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bot, Check, ChevronDown, Server, Settings2 } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
ModelSelectorContent,
|
||||||
|
ModelSelectorEmpty,
|
||||||
|
ModelSelectorGroup,
|
||||||
|
ModelSelectorInput,
|
||||||
|
ModelSelectorItem,
|
||||||
|
ModelSelectorList,
|
||||||
|
ModelSelectorLogo,
|
||||||
|
ModelSelectorName,
|
||||||
|
ModelSelector as ModelSelectorRoot,
|
||||||
|
ModelSelectorSeparator,
|
||||||
|
ModelSelectorTrigger,
|
||||||
|
} from "@/components/ai-elements/model-selector"
|
||||||
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
models: FlattenedModel[]
|
||||||
|
selectedModelId: string | undefined
|
||||||
|
onSelect: (modelId: string | undefined) => void
|
||||||
|
onConfigure: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map our provider names to models.dev logo names
|
||||||
|
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
google: "google",
|
||||||
|
azure: "azure",
|
||||||
|
bedrock: "amazon-bedrock",
|
||||||
|
openrouter: "openrouter",
|
||||||
|
deepseek: "deepseek",
|
||||||
|
siliconflow: "siliconflow",
|
||||||
|
gateway: "vercel",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
|
function groupModelsByProvider(
|
||||||
|
models: FlattenedModel[],
|
||||||
|
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ provider: string; models: FlattenedModel[] }
|
||||||
|
>()
|
||||||
|
for (const model of models) {
|
||||||
|
const key = model.providerLabel
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.models.push(model)
|
||||||
|
} else {
|
||||||
|
groups.set(key, { provider: model.provider, models: [model] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({
|
||||||
|
models,
|
||||||
|
selectedModelId,
|
||||||
|
onSelect,
|
||||||
|
onConfigure,
|
||||||
|
disabled = false,
|
||||||
|
}: ModelSelectorProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
// Only show validated models in the selector
|
||||||
|
const validatedModels = useMemo(
|
||||||
|
() => models.filter((m) => m.validated === true),
|
||||||
|
[models],
|
||||||
|
)
|
||||||
|
const groupedModels = useMemo(
|
||||||
|
() => groupModelsByProvider(validatedModels),
|
||||||
|
[validatedModels],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find selected model for display
|
||||||
|
const selectedModel = useMemo(
|
||||||
|
() => models.find((m) => m.id === selectedModelId),
|
||||||
|
[models, selectedModelId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (value === "__configure__") {
|
||||||
|
onConfigure()
|
||||||
|
} else if (value === "__server_default__") {
|
||||||
|
onSelect(undefined)
|
||||||
|
} else {
|
||||||
|
onSelect(value)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipContent = selectedModel
|
||||||
|
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||||
|
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||||
|
<ModelSelectorTrigger asChild>
|
||||||
|
<ButtonWithTooltip
|
||||||
|
tooltipContent={tooltipContent}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2"
|
||||||
|
>
|
||||||
|
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-xs truncate">
|
||||||
|
{selectedModel
|
||||||
|
? selectedModel.modelId
|
||||||
|
: dict.modelConfig.default}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
</ModelSelectorTrigger>
|
||||||
|
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||||
|
<ModelSelectorInput
|
||||||
|
placeholder={dict.modelConfig.searchModels}
|
||||||
|
/>
|
||||||
|
<ModelSelectorList>
|
||||||
|
<ModelSelectorEmpty>
|
||||||
|
{validatedModels.length === 0 && models.length > 0
|
||||||
|
? dict.modelConfig.noVerifiedModels
|
||||||
|
: dict.modelConfig.noModelsFound}
|
||||||
|
</ModelSelectorEmpty>
|
||||||
|
|
||||||
|
{/* Server Default Option */}
|
||||||
|
<ModelSelectorGroup heading={dict.modelConfig.default}>
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
!selectedModelId && "bg-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.serverDefault}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
|
||||||
|
{/* Configured Models by Provider */}
|
||||||
|
{Array.from(groupedModels.entries()).map(
|
||||||
|
([
|
||||||
|
providerLabel,
|
||||||
|
{ provider, models: providerModels },
|
||||||
|
]) => (
|
||||||
|
<ModelSelectorGroup
|
||||||
|
key={providerLabel}
|
||||||
|
heading={providerLabel}
|
||||||
|
>
|
||||||
|
{providerModels.map((model) => (
|
||||||
|
<ModelSelectorItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.modelId}
|
||||||
|
onSelect={() => handleSelect(model.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId === model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ModelSelectorLogo
|
||||||
|
provider={
|
||||||
|
PROVIDER_LOGO_MAP[provider] ||
|
||||||
|
provider
|
||||||
|
}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<ModelSelectorName>
|
||||||
|
{model.modelId}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
))}
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configure Option */}
|
||||||
|
<ModelSelectorSeparator />
|
||||||
|
<ModelSelectorGroup>
|
||||||
|
<ModelSelectorItem
|
||||||
|
value="__configure__"
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Settings2 className="mr-2 h-4 w-4" />
|
||||||
|
<ModelSelectorName>
|
||||||
|
{dict.modelConfig.configureModels}
|
||||||
|
</ModelSelectorName>
|
||||||
|
</ModelSelectorItem>
|
||||||
|
</ModelSelectorGroup>
|
||||||
|
{/* Info text */}
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
|
||||||
|
{dict.modelConfig.onlyVerifiedShown}
|
||||||
|
</div>
|
||||||
|
</ModelSelectorList>
|
||||||
|
</ModelSelectorContent>
|
||||||
|
</ModelSelectorRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -21,6 +22,40 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Reusable setting item component for consistent layout
|
||||||
|
function SettingItem({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||||
|
<div className="space-y-0.5 pr-4">
|
||||||
|
<Label className="text-sm font-medium">{label}</Label>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||||
|
en: "English",
|
||||||
|
zh: "中文",
|
||||||
|
ja: "日本語",
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -35,10 +70,6 @@ interface SettingsDialogProps {
|
|||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||||
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
|
|
||||||
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
|
|
||||||
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
|
|
||||||
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
|
|
||||||
|
|
||||||
function getStoredAccessCodeRequired(): boolean | null {
|
function getStoredAccessCodeRequired(): boolean | null {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
@@ -47,7 +78,7 @@ function getStoredAccessCodeRequired(): boolean | null {
|
|||||||
return stored === "true"
|
return stored === "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsDialog({
|
function SettingsContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCloseProtectionChange,
|
onCloseProtectionChange,
|
||||||
@@ -57,6 +88,9 @@ export function SettingsDialog({
|
|||||||
onToggleDarkMode,
|
onToggleDarkMode,
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname() || "/"
|
||||||
|
const search = useSearchParams()
|
||||||
const [accessCode, setAccessCode] = useState("")
|
const [accessCode, setAccessCode] = useState("")
|
||||||
const [closeProtection, setCloseProtection] = useState(true)
|
const [closeProtection, setCloseProtection] = useState(true)
|
||||||
const [isVerifying, setIsVerifying] = useState(false)
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
@@ -64,16 +98,13 @@ export function SettingsDialog({
|
|||||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||||
() => getStoredAccessCodeRequired() ?? false,
|
() => getStoredAccessCodeRequired() ?? false,
|
||||||
)
|
)
|
||||||
const [provider, setProvider] = useState("")
|
const [currentLang, setCurrentLang] = useState("en")
|
||||||
const [baseUrl, setBaseUrl] = useState("")
|
|
||||||
const [apiKey, setApiKey] = useState("")
|
|
||||||
const [modelId, setModelId] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if not cached in localStorage
|
// Only fetch if not cached in localStorage
|
||||||
if (getStoredAccessCodeRequired() !== null) return
|
if (getStoredAccessCodeRequired() !== null) return
|
||||||
|
|
||||||
fetch("/api/config")
|
fetch(getApiEndpoint("/api/config"))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -92,6 +123,17 @@ export function SettingsDialog({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Detect current language from pathname
|
||||||
|
useEffect(() => {
|
||||||
|
const seg = pathname.split("/").filter(Boolean)
|
||||||
|
const first = seg[0]
|
||||||
|
if (first && i18n.locales.includes(first as Locale)) {
|
||||||
|
setCurrentLang(first)
|
||||||
|
} else {
|
||||||
|
setCurrentLang(i18n.defaultLocale)
|
||||||
|
}
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
const storedCode =
|
const storedCode =
|
||||||
@@ -104,16 +146,22 @@ export function SettingsDialog({
|
|||||||
// Default to true if not set
|
// Default to true if not set
|
||||||
setCloseProtection(storedCloseProtection !== "false")
|
setCloseProtection(storedCloseProtection !== "false")
|
||||||
|
|
||||||
// Load AI provider settings
|
|
||||||
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
|
|
||||||
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
|
|
||||||
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
|
|
||||||
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
|
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
const changeLanguage = (lang: string) => {
|
||||||
|
const parts = pathname.split("/")
|
||||||
|
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||||
|
parts[1] = lang
|
||||||
|
} else {
|
||||||
|
parts.splice(1, 0, lang)
|
||||||
|
}
|
||||||
|
const newPath = parts.join("/") || "/"
|
||||||
|
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
||||||
|
router.push(newPath + searchStr)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!accessCodeRequired) return
|
if (!accessCodeRequired) return
|
||||||
|
|
||||||
@@ -121,12 +169,15 @@ export function SettingsDialog({
|
|||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/verify-access-code", {
|
const response = await fetch(
|
||||||
method: "POST",
|
getApiEndpoint("/api/verify-access-code"),
|
||||||
headers: {
|
{
|
||||||
"x-access-code": accessCode.trim(),
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-access-code": accessCode.trim(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
@@ -152,20 +203,32 @@ export function SettingsDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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>
|
<DialogDescription className="mt-1">
|
||||||
{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="space-y-2">
|
<div className="py-4 first:pt-0 space-y-3">
|
||||||
<Label htmlFor="access-code">
|
<div className="space-y-0.5">
|
||||||
{dict.settings.accessCode}
|
<Label
|
||||||
</Label>
|
htmlFor="access-code"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{dict.settings.accessCode}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.accessCodeDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="access-code"
|
id="access-code"
|
||||||
@@ -179,222 +242,60 @@ export function SettingsDialog({
|
|||||||
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-[0.8rem] text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{dict.settings.aiProvider}</Label>
|
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
|
||||||
{dict.settings.aiProviderDescription}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3 pt-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-provider">
|
|
||||||
{dict.settings.provider}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={provider || "default"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const actualValue =
|
|
||||||
value === "default" ? "" : value
|
|
||||||
setProvider(actualValue)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_AI_PROVIDER_KEY,
|
|
||||||
actualValue,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="ai-provider">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
dict.settings.useServerDefault
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">
|
|
||||||
{dict.settings.useServerDefault}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="openai">
|
|
||||||
{dict.providers.openai}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="anthropic">
|
|
||||||
{dict.providers.anthropic}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="google">
|
|
||||||
{dict.providers.google}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="azure">
|
|
||||||
{dict.providers.azure}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="openrouter">
|
|
||||||
{dict.providers.openrouter}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="deepseek">
|
|
||||||
{dict.providers.deepseek}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="siliconflow">
|
|
||||||
{dict.providers.siliconflow}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{provider && provider !== "default" && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-model">
|
|
||||||
{dict.settings.modelId}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ai-model"
|
|
||||||
value={modelId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setModelId(e.target.value)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_AI_MODEL_KEY,
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
provider === "openai"
|
|
||||||
? "e.g., gpt-4o"
|
|
||||||
: provider === "anthropic"
|
|
||||||
? "e.g., claude-sonnet-4-5"
|
|
||||||
: provider === "google"
|
|
||||||
? "e.g., gemini-2.0-flash-exp"
|
|
||||||
: provider ===
|
|
||||||
"deepseek"
|
|
||||||
? "e.g., deepseek-chat"
|
|
||||||
: dict.settings
|
|
||||||
.modelId
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-api-key">
|
|
||||||
{dict.settings.apiKey}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ai-api-key"
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => {
|
|
||||||
setApiKey(e.target.value)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_AI_API_KEY_KEY,
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
dict.settings.apiKeyPlaceholder
|
|
||||||
}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
|
||||||
{dict.settings.overrides}{" "}
|
|
||||||
{provider === "openai"
|
|
||||||
? "OPENAI_API_KEY"
|
|
||||||
: provider === "anthropic"
|
|
||||||
? "ANTHROPIC_API_KEY"
|
|
||||||
: provider === "google"
|
|
||||||
? "GOOGLE_GENERATIVE_AI_API_KEY"
|
|
||||||
: provider === "azure"
|
|
||||||
? "AZURE_API_KEY"
|
|
||||||
: provider ===
|
|
||||||
"openrouter"
|
|
||||||
? "OPENROUTER_API_KEY"
|
|
||||||
: provider ===
|
|
||||||
"deepseek"
|
|
||||||
? "DEEPSEEK_API_KEY"
|
|
||||||
: provider ===
|
|
||||||
"siliconflow"
|
|
||||||
? "SILICONFLOW_API_KEY"
|
|
||||||
: "server API key"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ai-base-url">
|
|
||||||
{dict.settings.baseUrl}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ai-base-url"
|
|
||||||
value={baseUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setBaseUrl(e.target.value)
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_AI_BASE_URL_KEY,
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
provider === "anthropic"
|
|
||||||
? "https://api.anthropic.com/v1"
|
|
||||||
: provider === "siliconflow"
|
|
||||||
? "https://api.siliconflow.com/v1"
|
|
||||||
: dict.settings
|
|
||||||
.customEndpoint
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
localStorage.removeItem(
|
|
||||||
STORAGE_AI_PROVIDER_KEY,
|
|
||||||
)
|
|
||||||
localStorage.removeItem(
|
|
||||||
STORAGE_AI_BASE_URL_KEY,
|
|
||||||
)
|
|
||||||
localStorage.removeItem(
|
|
||||||
STORAGE_AI_API_KEY_KEY,
|
|
||||||
)
|
|
||||||
localStorage.removeItem(
|
|
||||||
STORAGE_AI_MODEL_KEY,
|
|
||||||
)
|
|
||||||
setProvider("")
|
|
||||||
setBaseUrl("")
|
|
||||||
setApiKey("")
|
|
||||||
setModelId("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dict.settings.clearSettings}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Language */}
|
||||||
<div className="space-y-0.5">
|
<SettingItem
|
||||||
<Label htmlFor="theme-toggle">
|
label={dict.settings.language}
|
||||||
{dict.settings.theme}
|
description={dict.settings.languageDescription}
|
||||||
</Label>
|
>
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
<Select
|
||||||
{dict.settings.themeDescription}
|
value={currentLang}
|
||||||
</p>
|
onValueChange={changeLanguage}
|
||||||
</div>
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="language-select"
|
||||||
|
className="w-[120px] h-9 rounded-xl"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{i18n.locales.map((locale) => (
|
||||||
|
<SelectItem key={locale} value={locale}>
|
||||||
|
{LANGUAGE_LABELS[locale]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.theme}
|
||||||
|
description={dict.settings.themeDescription}
|
||||||
|
>
|
||||||
<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" />
|
||||||
@@ -402,42 +303,35 @@ export function SettingsDialog({
|
|||||||
<Moon className="h-4 w-4" />
|
<Moon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</SettingItem>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Draw.io Style */}
|
||||||
<div className="space-y-0.5">
|
<SettingItem
|
||||||
<Label htmlFor="drawio-ui">
|
label={dict.settings.drawioStyle}
|
||||||
{dict.settings.drawioStyle}
|
description={`${dict.settings.drawioStyleDescription} ${
|
||||||
</Label>
|
drawioUi === "min"
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
? dict.settings.minimal
|
||||||
{dict.settings.drawioStyleDescription}{" "}
|
: dict.settings.sketch
|
||||||
{drawioUi === "min"
|
}`}
|
||||||
? dict.settings.minimal
|
>
|
||||||
: 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>
|
||||||
</div>
|
</SettingItem>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Close Protection */}
|
||||||
<div className="space-y-0.5">
|
<SettingItem
|
||||||
<Label htmlFor="close-protection">
|
label={dict.settings.closeProtection}
|
||||||
{dict.settings.closeProtection}
|
description={dict.settings.closeProtectionDescription}
|
||||||
</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}
|
||||||
@@ -450,14 +344,34 @@ export function SettingsDialog({
|
|||||||
onCloseProtectionChange?.(checked)
|
onCloseProtectionChange?.(checked)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingItem>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-border/50">
|
</div>
|
||||||
<p className="text-[0.75rem] text-muted-foreground text-center">
|
|
||||||
Version {process.env.APP_VERSION}
|
{/* Footer */}
|
||||||
</p>
|
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||||
</div>
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
</DialogContent>
|
Version {process.env.APP_VERSION}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog(props: SettingsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<DialogContent className="sm:max-w-lg p-0">
|
||||||
|
<div className="h-80 flex items-center justify-center">
|
||||||
|
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsContent {...props} />
|
||||||
|
</Suspense>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
157
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
191
components/ui/command.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
// Ensure hover updates selection for visual feedback
|
||||||
|
const item = e.currentTarget
|
||||||
|
item.setAttribute("data-selected", "true")
|
||||||
|
// Deselect siblings
|
||||||
|
const siblings = item.parentElement?.querySelectorAll("[cmdk-item]")
|
||||||
|
siblings?.forEach((sibling) => {
|
||||||
|
if (sibling !== item) {
|
||||||
|
sibling.setAttribute("data-selected", "false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -38,7 +38,10 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px]",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
"duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -57,13 +60,32 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
// Base styles
|
||||||
|
"fixed top-[50%] left-[50%] z-50 w-full",
|
||||||
|
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||||
|
"grid gap-4 p-6",
|
||||||
|
// Refined visual treatment
|
||||||
|
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
||||||
|
// Entry/exit animations
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
"data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]",
|
||||||
|
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||||
|
"duration-200 sm:max-w-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<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">
|
<DialogPrimitive.Close className={cn(
|
||||||
|
"absolute top-4 right-4 rounded-xl p-1.5",
|
||||||
|
"text-muted-foreground/60 hover:text-foreground",
|
||||||
|
"hover:bg-interactive-hover",
|
||||||
|
"transition-all duration-150",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
"disabled:pointer-events-none",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4"
|
||||||
|
)}>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -102,7 +124,10 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn(
|
||||||
|
"text-xl font-semibold tracking-tight leading-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -115,7 +140,10 @@ function DialogDescription({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,30 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
// Base styles
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"flex h-10 w-full min-w-0 rounded-xl px-3.5 py-2",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"border border-border-subtle bg-surface-1",
|
||||||
|
"text-sm text-foreground",
|
||||||
|
// Placeholder
|
||||||
|
"placeholder:text-muted-foreground/60",
|
||||||
|
// Selection
|
||||||
|
"selection:bg-primary selection:text-primary-foreground",
|
||||||
|
// Transitions
|
||||||
|
"transition-all duration-150 ease-out",
|
||||||
|
// Hover state
|
||||||
|
"hover:border-border-default",
|
||||||
|
// Focus state - refined ring
|
||||||
|
"focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10",
|
||||||
|
// File input
|
||||||
|
"file:text-foreground file:inline-flex file:h-7 file:border-0",
|
||||||
|
"file:bg-transparent file:text-sm file:font-medium",
|
||||||
|
// Disabled
|
||||||
|
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
// Invalid state
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||||
|
"dark:aria-invalid:ring-destructive/40",
|
||||||
|
// Dark mode background
|
||||||
|
"dark:bg-surface-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
48
components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"
|
|||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
@@ -329,7 +330,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/log-save", {
|
await fetch(getApiEndpoint("/api/log-save"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ filename, format, sessionId }),
|
body: JSON.stringify({ filename, format, sessionId }),
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||||
|
# Uncomment below for subdirectory deployment
|
||||||
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# For subdirectory deployment, uncomment and set your path:
|
||||||
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
10
env.example
@@ -68,6 +68,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SILICONFLOW_API_KEY=sk-...
|
# SILICONFLOW_API_KEY=sk-...
|
||||||
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
|
# SILICONFLOW_BASE_URL=https://api.siliconflow.com/v1 # Optional: switch to https://api.siliconflow.cn/v1 if needed
|
||||||
|
|
||||||
|
# SGLang Configuration (OpenAI-compatible)
|
||||||
|
# SGLANG_API_KEY=your-sglang-api-key
|
||||||
|
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||||
|
|
||||||
# Vercel AI Gateway Configuration
|
# Vercel AI Gateway Configuration
|
||||||
# Get your API key from: https://vercel.com/ai-gateway
|
# Get your API key from: https://vercel.com/ai-gateway
|
||||||
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
# Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
@@ -93,6 +97,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||||
# Use this to point to a self-hosted draw.io instance
|
# Use this to point to a self-hosted draw.io instance
|
||||||
|
|
||||||
|
# Subdirectory Deployment (Optional)
|
||||||
|
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
|
||||||
|
# Set this to your subdirectory path with leading slash (e.g., /nextaidrawio)
|
||||||
|
# Leave empty for root deployment (default)
|
||||||
|
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
|
|
||||||
# PDF Input Feature (Optional)
|
# PDF Input Feature (Optional)
|
||||||
# Enable PDF file upload to extract text and generate diagrams
|
# Enable PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
# Enabled by default. Set to "false" to disable.
|
||||||
|
|||||||
373
hooks/use-model-config.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import {
|
||||||
|
createEmptyConfig,
|
||||||
|
createModelConfig,
|
||||||
|
createProviderConfig,
|
||||||
|
type FlattenedModel,
|
||||||
|
findModelById,
|
||||||
|
flattenModels,
|
||||||
|
type ModelConfig,
|
||||||
|
type MultiModelConfig,
|
||||||
|
PROVIDER_INFO,
|
||||||
|
type ProviderConfig,
|
||||||
|
type ProviderName,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
// Old storage keys for migration
|
||||||
|
const OLD_KEYS = {
|
||||||
|
aiProvider: "next-ai-draw-io-ai-provider",
|
||||||
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
|
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||||
|
aiModel: "next-ai-draw-io-ai-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate from old single-provider format to new multi-model format
|
||||||
|
*/
|
||||||
|
function migrateOldConfig(): MultiModelConfig | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
|
||||||
|
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
|
||||||
|
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
|
||||||
|
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
|
||||||
|
|
||||||
|
// No old config to migrate
|
||||||
|
if (!oldProvider || !oldApiKey || !oldModel) return null
|
||||||
|
|
||||||
|
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
|
||||||
|
|
||||||
|
// Create new config from old format
|
||||||
|
const provider = createProviderConfig(oldProvider as ProviderName)
|
||||||
|
provider.apiKey = oldApiKey
|
||||||
|
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
|
||||||
|
|
||||||
|
const model = createModelConfig(oldModel)
|
||||||
|
provider.models.push(model)
|
||||||
|
|
||||||
|
const config: MultiModelConfig = {
|
||||||
|
version: 1,
|
||||||
|
providers: [provider],
|
||||||
|
selectedModelId: model.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old keys after migration
|
||||||
|
localStorage.removeItem(OLD_KEYS.aiProvider)
|
||||||
|
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
|
||||||
|
localStorage.removeItem(OLD_KEYS.aiApiKey)
|
||||||
|
localStorage.removeItem(OLD_KEYS.aiModel)
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load config from localStorage
|
||||||
|
*/
|
||||||
|
function loadConfig(): MultiModelConfig {
|
||||||
|
if (typeof window === "undefined") return createEmptyConfig()
|
||||||
|
|
||||||
|
// First, check if new format exists
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored) as MultiModelConfig
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to parse model config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try migration from old format
|
||||||
|
const migrated = migrateOldConfig()
|
||||||
|
if (migrated) {
|
||||||
|
// Save migrated config
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.modelConfigs,
|
||||||
|
JSON.stringify(migrated),
|
||||||
|
)
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEmptyConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save config to localStorage
|
||||||
|
*/
|
||||||
|
function saveConfig(config: MultiModelConfig): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseModelConfigReturn {
|
||||||
|
// State
|
||||||
|
config: MultiModelConfig
|
||||||
|
isLoaded: boolean
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
models: FlattenedModel[]
|
||||||
|
selectedModel: FlattenedModel | undefined
|
||||||
|
selectedModelId: string | undefined
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSelectedModelId: (modelId: string | undefined) => void
|
||||||
|
addProvider: (provider: ProviderName) => ProviderConfig
|
||||||
|
updateProvider: (
|
||||||
|
providerId: string,
|
||||||
|
updates: Partial<ProviderConfig>,
|
||||||
|
) => void
|
||||||
|
deleteProvider: (providerId: string) => void
|
||||||
|
addModel: (providerId: string, modelId: string) => ModelConfig
|
||||||
|
updateModel: (
|
||||||
|
providerId: string,
|
||||||
|
modelConfigId: string,
|
||||||
|
updates: Partial<ModelConfig>,
|
||||||
|
) => void
|
||||||
|
deleteModel: (providerId: string, modelConfigId: string) => void
|
||||||
|
resetConfig: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelConfig(): UseModelConfigReturn {
|
||||||
|
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
|
||||||
|
// Load config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loaded = loadConfig()
|
||||||
|
setConfig(loaded)
|
||||||
|
setIsLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save config whenever it changes (after initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
saveConfig(config)
|
||||||
|
}
|
||||||
|
}, [config, isLoaded])
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const models = flattenModels(config)
|
||||||
|
const selectedModel = config.selectedModelId
|
||||||
|
? findModelById(config, config.selectedModelId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedModelId: modelId,
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addProvider = useCallback(
|
||||||
|
(provider: ProviderName): ProviderConfig => {
|
||||||
|
const newProvider = createProviderConfig(provider)
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: [...prev.providers, newProvider],
|
||||||
|
}))
|
||||||
|
return newProvider
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateProvider = useCallback(
|
||||||
|
(providerId: string, updates: Partial<ProviderConfig>) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: prev.providers.map((p) =>
|
||||||
|
p.id === providerId ? { ...p, ...updates } : p,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteProvider = useCallback((providerId: string) => {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const provider = prev.providers.find((p) => p.id === providerId)
|
||||||
|
const modelIds = provider?.models.map((m) => m.id) || []
|
||||||
|
|
||||||
|
// Clear selected model if it belongs to deleted provider
|
||||||
|
const newSelectedId =
|
||||||
|
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||||
|
? undefined
|
||||||
|
: prev.selectedModelId
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||||
|
selectedModelId: newSelectedId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addModel = useCallback(
|
||||||
|
(providerId: string, modelId: string): ModelConfig => {
|
||||||
|
const newModel = createModelConfig(modelId)
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: prev.providers.map((p) =>
|
||||||
|
p.id === providerId
|
||||||
|
? { ...p, models: [...p.models, newModel] }
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
return newModel
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateModel = useCallback(
|
||||||
|
(
|
||||||
|
providerId: string,
|
||||||
|
modelConfigId: string,
|
||||||
|
updates: Partial<ModelConfig>,
|
||||||
|
) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: prev.providers.map((p) =>
|
||||||
|
p.id === providerId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
models: p.models.map((m) =>
|
||||||
|
m.id === modelConfigId
|
||||||
|
? { ...m, ...updates }
|
||||||
|
: m,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteModel = useCallback(
|
||||||
|
(providerId: string, modelConfigId: string) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
providers: prev.providers.map((p) =>
|
||||||
|
p.id === providerId
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
models: p.models.filter(
|
||||||
|
(m) => m.id !== modelConfigId,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
// Clear selected model if it was deleted
|
||||||
|
selectedModelId:
|
||||||
|
prev.selectedModelId === modelConfigId
|
||||||
|
? undefined
|
||||||
|
: prev.selectedModelId,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetConfig = useCallback(() => {
|
||||||
|
setConfig(createEmptyConfig())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
isLoaded,
|
||||||
|
models,
|
||||||
|
selectedModel,
|
||||||
|
selectedModelId: config.selectedModelId,
|
||||||
|
setSelectedModelId,
|
||||||
|
addProvider,
|
||||||
|
updateProvider,
|
||||||
|
deleteProvider,
|
||||||
|
addModel,
|
||||||
|
updateModel,
|
||||||
|
deleteModel,
|
||||||
|
resetConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the AI config for the currently selected model.
|
||||||
|
* Returns format compatible with existing getAIConfig() usage.
|
||||||
|
*/
|
||||||
|
export function getSelectedAIConfig(): {
|
||||||
|
accessCode: string
|
||||||
|
aiProvider: string
|
||||||
|
aiBaseUrl: string
|
||||||
|
aiApiKey: string
|
||||||
|
aiModel: string
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: string
|
||||||
|
awsSecretAccessKey: string
|
||||||
|
awsRegion: string
|
||||||
|
awsSessionToken: string
|
||||||
|
} {
|
||||||
|
const empty = {
|
||||||
|
accessCode: "",
|
||||||
|
aiProvider: "",
|
||||||
|
aiBaseUrl: "",
|
||||||
|
aiApiKey: "",
|
||||||
|
aiModel: "",
|
||||||
|
awsAccessKeyId: "",
|
||||||
|
awsSecretAccessKey: "",
|
||||||
|
awsRegion: "",
|
||||||
|
awsSessionToken: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === "undefined") return empty
|
||||||
|
|
||||||
|
// Get access code (separate from model config)
|
||||||
|
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
|
||||||
|
|
||||||
|
// Load multi-model config
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||||
|
if (!stored) {
|
||||||
|
// Fallback to old format for backward compatibility
|
||||||
|
return {
|
||||||
|
accessCode,
|
||||||
|
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
|
||||||
|
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
|
||||||
|
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
|
||||||
|
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
|
||||||
|
// Old format didn't support AWS
|
||||||
|
awsAccessKeyId: "",
|
||||||
|
awsSecretAccessKey: "",
|
||||||
|
awsRegion: "",
|
||||||
|
awsSessionToken: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: MultiModelConfig
|
||||||
|
try {
|
||||||
|
config = JSON.parse(stored)
|
||||||
|
} catch {
|
||||||
|
return { ...empty, accessCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
// No selected model = use server default
|
||||||
|
if (!config.selectedModelId) {
|
||||||
|
return { ...empty, accessCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find selected model
|
||||||
|
const model = findModelById(config, config.selectedModelId)
|
||||||
|
if (!model) {
|
||||||
|
return { ...empty, accessCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessCode,
|
||||||
|
aiProvider: model.provider,
|
||||||
|
aiBaseUrl: model.baseUrl || "",
|
||||||
|
aiApiKey: model.apiKey,
|
||||||
|
aiModel: model.modelId,
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId: model.awsAccessKeyId || "",
|
||||||
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
||||||
|
awsRegion: model.awsRegion || "",
|
||||||
|
awsSessionToken: model.awsSessionToken || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,19 +14,14 @@ export function register() {
|
|||||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
||||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
baseUrl: process.env.LANGFUSE_BASEURL,
|
||||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
// Whitelist approach: only export AI-related spans
|
||||||
shouldExportSpan: ({ otelSpan }) => {
|
shouldExportSpan: ({ otelSpan }) => {
|
||||||
const spanName = otelSpan.name
|
const spanName = otelSpan.name
|
||||||
// Skip Next.js HTTP infrastructure spans
|
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper
|
||||||
if (
|
if (spanName === "chat" || spanName.startsWith("ai.")) {
|
||||||
spanName.startsWith("POST /") ||
|
return true
|
||||||
spanName.startsWith("GET /") ||
|
|
||||||
spanName.includes("BaseServer") ||
|
|
||||||
spanName.includes("handleRequest")
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
return false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,4 +31,5 @@ export function register() {
|
|||||||
|
|
||||||
// Register globally so AI SDK's telemetry also uses this processor
|
// Register globally so AI SDK's telemetry also uses this processor
|
||||||
tracerProvider.register()
|
tracerProvider.register()
|
||||||
|
console.log("[Langfuse] Instrumentation initialized successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type ProviderName =
|
|||||||
| "openrouter"
|
| "openrouter"
|
||||||
| "deepseek"
|
| "deepseek"
|
||||||
| "siliconflow"
|
| "siliconflow"
|
||||||
|
| "sglang"
|
||||||
| "gateway"
|
| "gateway"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
@@ -33,6 +34,11 @@ export interface ClientOverrides {
|
|||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
apiKey?: string | null
|
apiKey?: string | null
|
||||||
modelId?: string | null
|
modelId?: string | null
|
||||||
|
// AWS Bedrock credentials
|
||||||
|
awsAccessKeyId?: string | null
|
||||||
|
awsSecretAccessKey?: string | null
|
||||||
|
awsRegion?: string | null
|
||||||
|
awsSessionToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers that can be used with client-provided API keys
|
// Providers that can be used with client-provided API keys
|
||||||
@@ -41,9 +47,11 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"anthropic",
|
"anthropic",
|
||||||
"google",
|
"google",
|
||||||
"azure",
|
"azure",
|
||||||
|
"bedrock",
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"siliconflow",
|
"siliconflow",
|
||||||
|
"sglang",
|
||||||
"gateway",
|
"gateway",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -87,8 +95,8 @@ function parseIntSafe(
|
|||||||
* Supports various AI SDK providers with their unique configuration options
|
* Supports various AI SDK providers with their unique configuration options
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
|
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5
|
||||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
|
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5
|
||||||
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
||||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||||
@@ -110,18 +118,19 @@ function buildProviderOptions(
|
|||||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
||||||
|
|
||||||
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
|
// OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts
|
||||||
if (
|
if (
|
||||||
modelId &&
|
modelId &&
|
||||||
(modelId.includes("o1") ||
|
(modelId.includes("o1") ||
|
||||||
modelId.includes("o3") ||
|
modelId.includes("o3") ||
|
||||||
|
modelId.includes("o4") ||
|
||||||
modelId.includes("gpt-5"))
|
modelId.includes("gpt-5"))
|
||||||
) {
|
) {
|
||||||
options.openai = {
|
options.openai = {
|
||||||
// Auto-enable reasoning summary for reasoning models (default: detailed)
|
// Auto-enable reasoning summary for reasoning models
|
||||||
|
// Use 'auto' as default since not all models support 'detailed'
|
||||||
reasoningSummary:
|
reasoningSummary:
|
||||||
(reasoningSummary as "none" | "brief" | "detailed") ||
|
(reasoningSummary as "auto" | "detailed") || "auto",
|
||||||
"detailed",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally configure reasoning effort
|
// Optionally configure reasoning effort
|
||||||
@@ -144,8 +153,7 @@ function buildProviderOptions(
|
|||||||
}
|
}
|
||||||
if (reasoningSummary) {
|
if (reasoningSummary) {
|
||||||
options.openai.reasoningSummary = reasoningSummary as
|
options.openai.reasoningSummary = reasoningSummary as
|
||||||
| "none"
|
| "auto"
|
||||||
| "brief"
|
|
||||||
| "detailed"
|
| "detailed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +345,7 @@ function buildProviderOptions(
|
|||||||
case "deepseek":
|
case "deepseek":
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
|
case "sglang":
|
||||||
case "gateway": {
|
case "gateway": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
@@ -361,6 +370,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
deepseek: "DEEPSEEK_API_KEY",
|
deepseek: "DEEPSEEK_API_KEY",
|
||||||
siliconflow: "SILICONFLOW_API_KEY",
|
siliconflow: "SILICONFLOW_API_KEY",
|
||||||
|
sglang: "SGLANG_API_KEY",
|
||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +436,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -442,6 +452,8 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
|
||||||
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
* - SILICONFLOW_API_KEY: SiliconFlow API key
|
||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||||
|
* - SGLANG_API_KEY: SGLang API key
|
||||||
|
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||||
*/
|
*/
|
||||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
@@ -510,6 +522,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
`- OPENROUTER_API_KEY for OpenRouter\n` +
|
||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -537,12 +550,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "bedrock": {
|
case "bedrock": {
|
||||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||||
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
|
const hasClientCredentials =
|
||||||
const bedrockProvider = createAmazonBedrock({
|
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||||
region: process.env.AWS_REGION || "us-west-2",
|
const bedrockRegion =
|
||||||
credentialProvider: fromNodeProviderChain(),
|
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
||||||
})
|
|
||||||
|
const bedrockProvider = hasClientCredentials
|
||||||
|
? createAmazonBedrock({
|
||||||
|
region: bedrockRegion,
|
||||||
|
accessKeyId: overrides.awsAccessKeyId!,
|
||||||
|
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||||
|
...(overrides?.awsSessionToken && {
|
||||||
|
sessionToken: overrides.awsSessionToken,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: createAmazonBedrock({
|
||||||
|
region: bedrockRegion,
|
||||||
|
credentialProvider: fromNodeProviderChain(),
|
||||||
|
})
|
||||||
model = bedrockProvider(modelId)
|
model = bedrockProvider(modelId)
|
||||||
// Add Anthropic beta options if using Claude models via Bedrock
|
// Add Anthropic beta options if using Claude models via Bedrock
|
||||||
if (modelId.includes("anthropic.claude")) {
|
if (modelId.includes("anthropic.claude")) {
|
||||||
@@ -562,12 +588,16 @@ 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 || overrides?.apiKey) {
|
if (baseURL) {
|
||||||
const customOpenAI = createOpenAI({
|
// Custom base URL = third-party proxy, use Chat Completions API
|
||||||
apiKey,
|
// for compatibility (most proxies don't support /responses endpoint)
|
||||||
...(baseURL && { baseURL }),
|
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
||||||
})
|
|
||||||
model = customOpenAI.chat(modelId)
|
model = customOpenAI.chat(modelId)
|
||||||
|
} else if (overrides?.apiKey) {
|
||||||
|
// Custom API key but official OpenAI endpoint, use Responses API
|
||||||
|
// to support reasoning for gpt-5, o1, o3, o4 models
|
||||||
|
const customOpenAI = createOpenAI({ apiKey })
|
||||||
|
model = customOpenAI(modelId)
|
||||||
} else {
|
} else {
|
||||||
model = openai(modelId)
|
model = openai(modelId)
|
||||||
}
|
}
|
||||||
@@ -679,6 +709,112 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "sglang": {
|
||||||
|
const apiKey = overrides?.apiKey || process.env.SGLANG_API_KEY
|
||||||
|
const baseURL = overrides?.baseUrl || process.env.SGLANG_BASE_URL
|
||||||
|
|
||||||
|
const sglangProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
// Add a custom fetch wrapper to intercept and fix the stream from sglang
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const response = await fetch(url, options)
|
||||||
|
if (!response.body) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a transform stream to fix the non-compliant sglang stream
|
||||||
|
let buffer = ""
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
const transformStream = new TransformStream({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true })
|
||||||
|
// Process all complete messages in the buffer
|
||||||
|
let messageEndPos
|
||||||
|
while (
|
||||||
|
(messageEndPos = buffer.indexOf("\n\n")) !== -1
|
||||||
|
) {
|
||||||
|
const message = buffer.substring(
|
||||||
|
0,
|
||||||
|
messageEndPos,
|
||||||
|
)
|
||||||
|
buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n'
|
||||||
|
|
||||||
|
if (message.startsWith("data: ")) {
|
||||||
|
const jsonStr = message.substring(6).trim()
|
||||||
|
if (jsonStr === "[DONE]") {
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
message + "\n\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
const delta = data.choices?.[0]?.delta
|
||||||
|
|
||||||
|
if (delta) {
|
||||||
|
// Fix 1: remove invalid empty role
|
||||||
|
if (delta.role === "") {
|
||||||
|
delete delta.role
|
||||||
|
}
|
||||||
|
// Fix 2: remove non-standard reasoning_content field
|
||||||
|
if ("reasoning_content" in delta) {
|
||||||
|
delete delta.reasoning_content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-serialize and forward the corrected data with the correct SSE format
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`data: ${JSON.stringify(data)}\n\n`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, forward the original message to avoid breaking the stream.
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
message + "\n\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (message.trim() !== "") {
|
||||||
|
// Pass through other message types (e.g., 'event: ...')
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
message + "\n\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush(controller) {
|
||||||
|
// If there's anything left in the buffer, forward it.
|
||||||
|
if (buffer.trim()) {
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(buffer),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const transformedBody =
|
||||||
|
response.body.pipeThrough(transformStream)
|
||||||
|
|
||||||
|
// Return a new response with the transformed body
|
||||||
|
return new Response(transformedBody, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
model = sglangProvider.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "gateway": {
|
case "gateway": {
|
||||||
// Vercel AI Gateway - unified access to multiple AI providers
|
// Vercel AI Gateway - unified access to multiple AI providers
|
||||||
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
// Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5"
|
||||||
@@ -702,7 +838,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, gateway`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
lib/base-path.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Get the base path for API calls and static assets
|
||||||
|
* This is used for subdirectory deployment support
|
||||||
|
*
|
||||||
|
* Example: If deployed at https://example.com/nextaidrawio, this returns "/nextaidrawio"
|
||||||
|
* For root deployment, this returns ""
|
||||||
|
*
|
||||||
|
* Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
|
||||||
|
*/
|
||||||
|
export function getBasePath(): string {
|
||||||
|
// Read from environment variable (must start with NEXT_PUBLIC_ to be available on client)
|
||||||
|
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""
|
||||||
|
if (basePath && !basePath.startsWith("/")) {
|
||||||
|
console.warn("NEXT_PUBLIC_BASE_PATH should start with /")
|
||||||
|
}
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full API endpoint URL
|
||||||
|
* @param endpoint - API endpoint path (e.g., "/api/chat", "/api/config")
|
||||||
|
* @returns Full API path with base path prefix
|
||||||
|
*/
|
||||||
|
export function getApiEndpoint(endpoint: string): string {
|
||||||
|
const basePath = getBasePath()
|
||||||
|
return `${basePath}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full static asset URL
|
||||||
|
* @param assetPath - Asset path (e.g., "/example.png", "/chain-of-thought.txt")
|
||||||
|
* @returns Full asset path with base path prefix
|
||||||
|
*/
|
||||||
|
export function getAssetUrl(assetPath: string): string {
|
||||||
|
const basePath = getBasePath()
|
||||||
|
return `${basePath}${assetPath}`
|
||||||
|
}
|
||||||
297
lib/dynamo-quota-manager.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
ConditionalCheckFailedException,
|
||||||
|
DynamoDBClient,
|
||||||
|
GetItemCommand,
|
||||||
|
UpdateItemCommand,
|
||||||
|
} from "@aws-sdk/client-dynamodb"
|
||||||
|
|
||||||
|
// Quota tracking is OPT-IN: only enabled if DYNAMODB_QUOTA_TABLE is explicitly set
|
||||||
|
// OSS users who don't need quota tracking can simply not set this env var
|
||||||
|
const TABLE = process.env.DYNAMODB_QUOTA_TABLE
|
||||||
|
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
|
||||||
|
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
|
||||||
|
// Defaults to UTC if not set
|
||||||
|
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
|
||||||
|
|
||||||
|
// Validate timezone at module load
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
|
||||||
|
new Date(),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
|
||||||
|
)
|
||||||
|
QUOTA_TIMEZONE = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's date string in the configured timezone (YYYY-MM-DD format)
|
||||||
|
*/
|
||||||
|
function getTodayInTimezone(): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: QUOTA_TIMEZONE,
|
||||||
|
}).format(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create client if quota is enabled
|
||||||
|
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if server-side quota tracking is enabled.
|
||||||
|
* Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set.
|
||||||
|
*/
|
||||||
|
export function isQuotaEnabled(): boolean {
|
||||||
|
return !!TABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotaLimits {
|
||||||
|
requests: number // Daily request limit
|
||||||
|
tokens: number // Daily token limit
|
||||||
|
tpm: number // Tokens per minute
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotaCheckResult {
|
||||||
|
allowed: boolean
|
||||||
|
error?: string
|
||||||
|
type?: "request" | "token" | "tpm"
|
||||||
|
used?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all quotas and increment request count atomically.
|
||||||
|
* Uses ConditionExpression to prevent race conditions.
|
||||||
|
* Returns which limit was exceeded if any.
|
||||||
|
*/
|
||||||
|
export async function checkAndIncrementRequest(
|
||||||
|
ip: string,
|
||||||
|
limits: QuotaLimits,
|
||||||
|
): Promise<QuotaCheckResult> {
|
||||||
|
// Skip if quota tracking not enabled
|
||||||
|
if (!client || !TABLE) {
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = getTodayInTimezone()
|
||||||
|
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||||
|
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, try to reset counts if it's a new day (atomic day reset)
|
||||||
|
// This will succeed only if lastResetDate < today or doesn't exist
|
||||||
|
try {
|
||||||
|
await client.send(
|
||||||
|
new UpdateItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: { PK: { S: `IP#${ip}` } },
|
||||||
|
// Reset all counts to 1/0 for the new day
|
||||||
|
UpdateExpression: `
|
||||||
|
SET lastResetDate = :today,
|
||||||
|
dailyReqCount = :one,
|
||||||
|
dailyTokenCount = :zero,
|
||||||
|
lastMinute = :minute,
|
||||||
|
tpmCount = :zero,
|
||||||
|
#ttl = :ttl
|
||||||
|
`,
|
||||||
|
// Only succeed if it's a new day (or new item)
|
||||||
|
ConditionExpression: `
|
||||||
|
attribute_not_exists(lastResetDate) OR lastResetDate < :today
|
||||||
|
`,
|
||||||
|
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
":today": { S: today },
|
||||||
|
":zero": { N: "0" },
|
||||||
|
":one": { N: "1" },
|
||||||
|
":minute": { S: currentMinute },
|
||||||
|
":ttl": { N: String(ttl) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// New day reset successful
|
||||||
|
return { allowed: true }
|
||||||
|
} catch (resetError: any) {
|
||||||
|
// If condition failed, it's the same day - continue to increment logic
|
||||||
|
if (!(resetError instanceof ConditionalCheckFailedException)) {
|
||||||
|
throw resetError // Re-throw unexpected errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same day - increment request count with limit checks
|
||||||
|
await client.send(
|
||||||
|
new UpdateItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: { PK: { S: `IP#${ip}` } },
|
||||||
|
// Increment request count, handle minute boundary for TPM
|
||||||
|
UpdateExpression: `
|
||||||
|
SET lastMinute = :minute,
|
||||||
|
tpmCount = if_not_exists(tpmCount, :zero),
|
||||||
|
#ttl = :ttl
|
||||||
|
ADD dailyReqCount :one
|
||||||
|
`,
|
||||||
|
// Check all limits before allowing increment
|
||||||
|
ConditionExpression: `
|
||||||
|
lastResetDate = :today AND
|
||||||
|
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
|
||||||
|
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND
|
||||||
|
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
|
||||||
|
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
|
||||||
|
`,
|
||||||
|
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
":today": { S: today },
|
||||||
|
":zero": { N: "0" },
|
||||||
|
":one": { N: "1" },
|
||||||
|
":minute": { S: currentMinute },
|
||||||
|
":ttl": { N: String(ttl) },
|
||||||
|
":reqLimit": { N: String(limits.requests || 999999) },
|
||||||
|
":tokenLimit": { N: String(limits.tokens || 999999) },
|
||||||
|
":tpmLimit": { N: String(limits.tpm || 999999) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { allowed: true }
|
||||||
|
} catch (e: any) {
|
||||||
|
// Condition failed - need to determine which limit was exceeded
|
||||||
|
if (e instanceof ConditionalCheckFailedException) {
|
||||||
|
// Get current counts to determine which limit was hit
|
||||||
|
try {
|
||||||
|
const getResult = await client.send(
|
||||||
|
new GetItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: { PK: { S: `IP#${ip}` } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const item = getResult.Item
|
||||||
|
const storedDate = item?.lastResetDate?.S
|
||||||
|
const storedMinute = item?.lastMinute?.S
|
||||||
|
const isNewDay = !storedDate || storedDate < today
|
||||||
|
|
||||||
|
const dailyReqCount = isNewDay
|
||||||
|
? 0
|
||||||
|
: Number(item?.dailyReqCount?.N || 0)
|
||||||
|
const dailyTokenCount = isNewDay
|
||||||
|
? 0
|
||||||
|
: Number(item?.dailyTokenCount?.N || 0)
|
||||||
|
const tpmCount =
|
||||||
|
storedMinute !== currentMinute
|
||||||
|
? 0
|
||||||
|
: Number(item?.tpmCount?.N || 0)
|
||||||
|
|
||||||
|
// Determine which limit was exceeded
|
||||||
|
if (limits.requests > 0 && dailyReqCount >= limits.requests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
type: "request",
|
||||||
|
error: "Daily request limit exceeded",
|
||||||
|
used: dailyReqCount,
|
||||||
|
limit: limits.requests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (limits.tokens > 0 && dailyTokenCount >= limits.tokens) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
type: "token",
|
||||||
|
error: "Daily token limit exceeded",
|
||||||
|
used: dailyTokenCount,
|
||||||
|
limit: limits.tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
type: "tpm",
|
||||||
|
error: "Rate limit exceeded (tokens per minute)",
|
||||||
|
used: tpmCount,
|
||||||
|
limit: limits.tpm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition failed but no limit clearly exceeded - race condition edge case
|
||||||
|
// Fail safe by allowing (could be a reset race)
|
||||||
|
console.warn(
|
||||||
|
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
|
||||||
|
)
|
||||||
|
return { allowed: true }
|
||||||
|
} catch (getError: any) {
|
||||||
|
console.error(
|
||||||
|
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
|
||||||
|
)
|
||||||
|
return { allowed: true } // Fail open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other DynamoDB errors - fail open
|
||||||
|
console.error(
|
||||||
|
`[quota] DynamoDB error (fail-open), IP prefix: ${ip.slice(0, 8)}..., error: ${e.message}`,
|
||||||
|
)
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record token usage after response completes.
|
||||||
|
* Uses atomic operations to update both daily token count and TPM count.
|
||||||
|
* Handles minute boundaries atomically to prevent race conditions.
|
||||||
|
*/
|
||||||
|
export async function recordTokenUsage(
|
||||||
|
ip: string,
|
||||||
|
tokens: number,
|
||||||
|
): Promise<void> {
|
||||||
|
// Skip if quota tracking not enabled
|
||||||
|
if (!client || !TABLE) return
|
||||||
|
if (!Number.isFinite(tokens) || tokens <= 0) return
|
||||||
|
|
||||||
|
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||||
|
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to update assuming same minute (most common case)
|
||||||
|
// Uses condition to ensure we're in the same minute
|
||||||
|
await client.send(
|
||||||
|
new UpdateItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: { PK: { S: `IP#${ip}` } },
|
||||||
|
UpdateExpression:
|
||||||
|
"SET #ttl = :ttl ADD dailyTokenCount :tokens, tpmCount :tokens",
|
||||||
|
ConditionExpression: "lastMinute = :minute",
|
||||||
|
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
":minute": { S: currentMinute },
|
||||||
|
":tokens": { N: String(tokens) },
|
||||||
|
":ttl": { N: String(ttl) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof ConditionalCheckFailedException) {
|
||||||
|
// Different minute - reset TPM count and set new minute
|
||||||
|
try {
|
||||||
|
await client.send(
|
||||||
|
new UpdateItemCommand({
|
||||||
|
TableName: TABLE,
|
||||||
|
Key: { PK: { S: `IP#${ip}` } },
|
||||||
|
UpdateExpression:
|
||||||
|
"SET lastMinute = :minute, tpmCount = :tokens, #ttl = :ttl ADD dailyTokenCount :tokens",
|
||||||
|
ExpressionAttributeNames: { "#ttl": "ttl" },
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
":minute": { S: currentMinute },
|
||||||
|
":tokens": { N: String(tokens) },
|
||||||
|
":ttl": { N: String(ttl) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (retryError: any) {
|
||||||
|
console.error(
|
||||||
|
`[quota] Failed to record tokens (retry), IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${retryError.message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`[quota] Failed to record tokens, IP prefix: ${ip.slice(0, 8)}..., tokens: ${tokens}, error: ${e.message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"newChat": "Start fresh chat",
|
"newChat": "Start fresh chat",
|
||||||
|
"github": "GitHub",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"hidePanel": "Hide chat panel (Ctrl+B)",
|
"hidePanel": "Hide chat panel (Ctrl+B)",
|
||||||
"showPanel": "Show chat panel (Ctrl+B)",
|
"showPanel": "Show chat panel (Ctrl+B)",
|
||||||
@@ -87,6 +88,8 @@
|
|||||||
"overrides": "Overrides",
|
"overrides": "Overrides",
|
||||||
"clearSettings": "Clear Settings",
|
"clearSettings": "Clear Settings",
|
||||||
"useServerDefault": "Use Server Default",
|
"useServerDefault": "Use Server Default",
|
||||||
|
"language": "Language",
|
||||||
|
"languageDescription": "Choose your interface language.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
|
||||||
"drawioStyle": "DrawIO Style",
|
"drawioStyle": "DrawIO Style",
|
||||||
@@ -147,6 +150,7 @@
|
|||||||
"tokenLimit": "Daily Token Limit Reached",
|
"tokenLimit": "Daily Token Limit Reached",
|
||||||
"tpmLimit": "Rate Limit",
|
"tpmLimit": "Rate Limit",
|
||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
"tpmMessage": "Too many requests. Please wait a moment.",
|
||||||
|
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||||
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
"messageApi": "Oops — you've reached the daily API limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
||||||
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
"messageToken": "Oops — you've reached the daily token limit for this demo! As an indie developer covering all the API costs myself, I have to set these limits to keep things sustainable.",
|
||||||
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||||
@@ -180,5 +184,65 @@
|
|||||||
"seekingSponsorship": "Call for Sponsorship",
|
"seekingSponsorship": "Call for Sponsorship",
|
||||||
"contactMe": "Contact Me",
|
"contactMe": "Contact Me",
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||||
|
},
|
||||||
|
"modelConfig": {
|
||||||
|
"title": "AI Model Configuration",
|
||||||
|
"description": "Configure multiple AI providers and models",
|
||||||
|
"configure": "Configure",
|
||||||
|
"addProvider": "Add Provider",
|
||||||
|
"addModel": "Add Model",
|
||||||
|
"modelId": "Model ID",
|
||||||
|
"modelLabel": "Display Label",
|
||||||
|
"streaming": "Enable Streaming",
|
||||||
|
"deleteProvider": "Delete Provider",
|
||||||
|
"deleteModel": "Delete Model",
|
||||||
|
"noModels": "No models configured. Add a model to get started.",
|
||||||
|
"selectProvider": "Select a provider or add a new one",
|
||||||
|
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
||||||
|
"apiKeyStored": "API keys are stored locally in your browser",
|
||||||
|
"test": "Test",
|
||||||
|
"validationError": "Validation failed",
|
||||||
|
"addModelFirst": "Add at least one model to validate",
|
||||||
|
"providers": "Providers",
|
||||||
|
"addProviderHint": "Add a provider to get started",
|
||||||
|
"verified": "Verified",
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"awsAccessKeyId": "AWS Access Key ID",
|
||||||
|
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||||
|
"awsRegion": "AWS Region",
|
||||||
|
"selectRegion": "Select region",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"enterApiKey": "Enter your API key",
|
||||||
|
"enterSecretKey": "Enter your secret access key",
|
||||||
|
"baseUrl": "Base URL",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"customEndpoint": "Custom endpoint URL",
|
||||||
|
"models": "Models",
|
||||||
|
"customModelId": "Custom model ID...",
|
||||||
|
"allAdded": "All added",
|
||||||
|
"suggested": "Suggested",
|
||||||
|
"noModelsConfigured": "No models configured",
|
||||||
|
"modelIdEmpty": "Model ID cannot be empty",
|
||||||
|
"modelIdExists": "This model ID already exists",
|
||||||
|
"configureProviders": "Configure AI Providers",
|
||||||
|
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
|
||||||
|
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
|
||||||
|
"typeToConfirm": "Type \"{name}\" to confirm",
|
||||||
|
"typeProviderName": "Type provider name...",
|
||||||
|
"modelsConfiguredCount": "{count} model(s) configured",
|
||||||
|
"validationFailedCount": "{count} model(s) failed validation",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"clickToChange": "(click to change)",
|
||||||
|
"usingServerDefault": "Using server default model",
|
||||||
|
"selectModel": "Select Model",
|
||||||
|
"searchModels": "Search models...",
|
||||||
|
"noVerifiedModels": "No verified models. Test your models first.",
|
||||||
|
"noModelsFound": "No models found.",
|
||||||
|
"default": "Default",
|
||||||
|
"serverDefault": "Server Default",
|
||||||
|
"configureModels": "Configure Models...",
|
||||||
|
"onlyVerifiedShown": "Only verified models are shown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"about": "概要",
|
"about": "概要",
|
||||||
"editor": "エディタ",
|
"editor": "エディタ",
|
||||||
"newChat": "新しいチャットを開始",
|
"newChat": "新しいチャットを開始",
|
||||||
|
"github": "GitHub",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
"hidePanel": "チャットパネルを非表示 (Ctrl+B)",
|
||||||
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
"showPanel": "チャットパネルを表示 (Ctrl+B)",
|
||||||
@@ -87,6 +88,8 @@
|
|||||||
"overrides": "上書き",
|
"overrides": "上書き",
|
||||||
"clearSettings": "設定をクリア",
|
"clearSettings": "設定をクリア",
|
||||||
"useServerDefault": "サーバーデフォルトを使用",
|
"useServerDefault": "サーバーデフォルトを使用",
|
||||||
|
"language": "言語",
|
||||||
|
"languageDescription": "インターフェース言語を選択します。",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
|
||||||
"drawioStyle": "DrawIO スタイル",
|
"drawioStyle": "DrawIO スタイル",
|
||||||
@@ -147,6 +150,7 @@
|
|||||||
"tokenLimit": "1日のトークン制限に達しました",
|
"tokenLimit": "1日のトークン制限に達しました",
|
||||||
"tpmLimit": "レート制限",
|
"tpmLimit": "レート制限",
|
||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
"messageApi": "おっと — このデモの1日の API 制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||||
"messageToken": "おっと — このデモの1日のトークン制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
"messageToken": "おっと — このデモの1日のトークン制限に達しました!個人開発者として API コストをすべて負担しているため、持続可能性を保つためにこれらの制限を設定する必要があります。",
|
||||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
@@ -180,5 +184,65 @@
|
|||||||
"seekingSponsorship": "スポンサー募集",
|
"seekingSponsorship": "スポンサー募集",
|
||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||||
|
},
|
||||||
|
"modelConfig": {
|
||||||
|
"title": "AIモデル設定",
|
||||||
|
"description": "複数のAIプロバイダーとモデルを設定",
|
||||||
|
"configure": "設定",
|
||||||
|
"addProvider": "プロバイダーを追加",
|
||||||
|
"addModel": "モデルを追加",
|
||||||
|
"modelId": "モデルID",
|
||||||
|
"modelLabel": "表示名",
|
||||||
|
"streaming": "ストリーミングを有効",
|
||||||
|
"deleteProvider": "プロバイダーを削除",
|
||||||
|
"deleteModel": "モデルを削除",
|
||||||
|
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
||||||
|
"selectProvider": "プロバイダーを選択または追加してください",
|
||||||
|
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
||||||
|
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
||||||
|
"test": "テスト",
|
||||||
|
"validationError": "検証に失敗しました",
|
||||||
|
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
||||||
|
"providers": "プロバイダー",
|
||||||
|
"addProviderHint": "プロバイダーを追加して開始",
|
||||||
|
"verified": "検証済み",
|
||||||
|
"configuration": "設定",
|
||||||
|
"displayName": "表示名",
|
||||||
|
"awsAccessKeyId": "AWS アクセスキー ID",
|
||||||
|
"awsSecretAccessKey": "AWS シークレットアクセスキー",
|
||||||
|
"awsRegion": "AWS リージョン",
|
||||||
|
"selectRegion": "リージョンを選択",
|
||||||
|
"apiKey": "API キー",
|
||||||
|
"enterApiKey": "API キーを入力",
|
||||||
|
"enterSecretKey": "シークレットアクセスキーを入力",
|
||||||
|
"baseUrl": "ベース URL",
|
||||||
|
"optional": "(オプション)",
|
||||||
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
|
"models": "モデル",
|
||||||
|
"customModelId": "カスタムモデル ID...",
|
||||||
|
"allAdded": "すべて追加済み",
|
||||||
|
"suggested": "おすすめ",
|
||||||
|
"noModelsConfigured": "モデルが設定されていません",
|
||||||
|
"modelIdEmpty": "モデル ID は空にできません",
|
||||||
|
"modelIdExists": "このモデル ID は既に存在します",
|
||||||
|
"configureProviders": "AI プロバイダーを設定",
|
||||||
|
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
|
||||||
|
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||||
|
"typeToConfirm": "確認のため「{name}」と入力",
|
||||||
|
"typeProviderName": "プロバイダー名を入力...",
|
||||||
|
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
|
||||||
|
"validationFailedCount": "{count} 個のモデルの検証に失敗",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"delete": "削除",
|
||||||
|
"clickToChange": "(クリックして変更)",
|
||||||
|
"usingServerDefault": "サーバーデフォルトモデルを使用中",
|
||||||
|
"selectModel": "モデルを選択",
|
||||||
|
"searchModels": "モデルを検索...",
|
||||||
|
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
|
||||||
|
"noModelsFound": "モデルが見つかりません。",
|
||||||
|
"default": "デフォルト",
|
||||||
|
"serverDefault": "サーバーデフォルト",
|
||||||
|
"configureModels": "モデルを設定...",
|
||||||
|
"onlyVerifiedShown": "検証済みのモデルのみ表示"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"about": "关于",
|
"about": "关于",
|
||||||
"editor": "编辑器",
|
"editor": "编辑器",
|
||||||
"newChat": "开始新对话",
|
"newChat": "开始新对话",
|
||||||
|
"github": "GitHub",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
"hidePanel": "隐藏聊天面板 (Ctrl+B)",
|
||||||
"showPanel": "显示聊天面板 (Ctrl+B)",
|
"showPanel": "显示聊天面板 (Ctrl+B)",
|
||||||
@@ -87,6 +88,8 @@
|
|||||||
"overrides": "覆盖",
|
"overrides": "覆盖",
|
||||||
"clearSettings": "清除设置",
|
"clearSettings": "清除设置",
|
||||||
"useServerDefault": "使用服务器默认值",
|
"useServerDefault": "使用服务器默认值",
|
||||||
|
"language": "语言",
|
||||||
|
"languageDescription": "选择界面语言。",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
|
||||||
"drawioStyle": "DrawIO 样式",
|
"drawioStyle": "DrawIO 样式",
|
||||||
@@ -147,6 +150,7 @@
|
|||||||
"tokenLimit": "已达每日令牌限制",
|
"tokenLimit": "已达每日令牌限制",
|
||||||
"tpmLimit": "速率限制",
|
"tpmLimit": "速率限制",
|
||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
|
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
||||||
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
"messageApi": "糟糕 — 您已达到此演示的每日 API 限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||||
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
"messageToken": "糟糕 — 您已达到此演示的每日令牌限制!作为一名独立开发者,我自己承担所有 API 费用,因此必须设置这些限制以保持可持续性。",
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
@@ -180,5 +184,65 @@
|
|||||||
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
"seekingSponsorship": "寻求赞助(求大佬捞一把)",
|
||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||||
|
},
|
||||||
|
"modelConfig": {
|
||||||
|
"title": "AI 模型配置",
|
||||||
|
"description": "配置多个 AI 提供商和模型",
|
||||||
|
"configure": "配置",
|
||||||
|
"addProvider": "添加提供商",
|
||||||
|
"addModel": "添加模型",
|
||||||
|
"modelId": "模型 ID",
|
||||||
|
"modelLabel": "显示名称",
|
||||||
|
"streaming": "启用流式输出",
|
||||||
|
"deleteProvider": "删除提供商",
|
||||||
|
"deleteModel": "删除模型",
|
||||||
|
"noModels": "尚未配置模型。添加模型以开始使用。",
|
||||||
|
"selectProvider": "选择一个提供商或添加新的",
|
||||||
|
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
||||||
|
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
||||||
|
"test": "测试",
|
||||||
|
"validationError": "验证失败",
|
||||||
|
"addModelFirst": "请先添加至少一个模型以进行验证",
|
||||||
|
"providers": "提供商",
|
||||||
|
"addProviderHint": "添加提供商即可开始使用",
|
||||||
|
"verified": "已验证",
|
||||||
|
"configuration": "配置",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"awsAccessKeyId": "AWS 访问密钥 ID",
|
||||||
|
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||||
|
"awsRegion": "AWS 区域",
|
||||||
|
"selectRegion": "选择区域",
|
||||||
|
"apiKey": "API 密钥",
|
||||||
|
"enterApiKey": "输入您的 API 密钥",
|
||||||
|
"enterSecretKey": "输入您的 Secret Key",
|
||||||
|
"baseUrl": "基础 URL",
|
||||||
|
"optional": "(可选)",
|
||||||
|
"customEndpoint": "自定义端点 URL",
|
||||||
|
"models": "模型",
|
||||||
|
"customModelId": "自定义模型 ID...",
|
||||||
|
"allAdded": "已全部添加",
|
||||||
|
"suggested": "推荐",
|
||||||
|
"noModelsConfigured": "尚未配置模型",
|
||||||
|
"modelIdEmpty": "模型 ID 不能为空",
|
||||||
|
"modelIdExists": "此模型 ID 已存在",
|
||||||
|
"configureProviders": "配置 AI 提供商",
|
||||||
|
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
|
||||||
|
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
|
||||||
|
"typeToConfirm": "输入 \"{name}\" 以确认",
|
||||||
|
"typeProviderName": "输入提供商名称...",
|
||||||
|
"modelsConfiguredCount": "已配置 {count} 个模型",
|
||||||
|
"validationFailedCount": "{count} 个模型验证失败",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "删除",
|
||||||
|
"clickToChange": "(点击更改)",
|
||||||
|
"usingServerDefault": "使用服务器默认模型",
|
||||||
|
"selectModel": "选择模型",
|
||||||
|
"searchModels": "搜索模型...",
|
||||||
|
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
|
||||||
|
"noModelsFound": "未找到模型。",
|
||||||
|
"default": "默认",
|
||||||
|
"serverDefault": "服务器默认",
|
||||||
|
"configureModels": "配置模型...",
|
||||||
|
"onlyVerifiedShown": "仅显示已验证的模型"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ export function getLangfuseClient(): LangfuseClient | null {
|
|||||||
return langfuseClient
|
return langfuseClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if Langfuse is configured
|
// Check if Langfuse is configured (both keys required)
|
||||||
export function isLangfuseEnabled(): boolean {
|
export function isLangfuseEnabled(): boolean {
|
||||||
return !!process.env.LANGFUSE_PUBLIC_KEY
|
return !!(
|
||||||
|
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
|
||||||
@@ -43,34 +45,16 @@ export function setTraceInput(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update trace with output and end the span
|
// Update trace with output and end the span
|
||||||
export function setTraceOutput(
|
// Note: AI SDK 6 telemetry automatically reports token usage on its spans,
|
||||||
output: string,
|
// so we only need to set the output text and close our wrapper span
|
||||||
usage?: { promptTokens?: number; completionTokens?: number },
|
export function setTraceOutput(output: string) {
|
||||||
) {
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ export const STORAGE_KEYS = {
|
|||||||
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
aiBaseUrl: "next-ai-draw-io-ai-base-url",
|
||||||
aiApiKey: "next-ai-draw-io-ai-api-key",
|
aiApiKey: "next-ai-draw-io-ai-api-key",
|
||||||
aiModel: "next-ai-draw-io-ai-model",
|
aiModel: "next-ai-draw-io-ai-model",
|
||||||
|
|
||||||
|
// Multi-model configuration
|
||||||
|
modelConfigs: "next-ai-draw-io-model-configs",
|
||||||
|
selectedModelId: "next-ai-draw-io-selected-model-id",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
277
lib/types/model-config.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// Types for multi-provider model configuration
|
||||||
|
|
||||||
|
export type ProviderName =
|
||||||
|
| "openai"
|
||||||
|
| "anthropic"
|
||||||
|
| "google"
|
||||||
|
| "azure"
|
||||||
|
| "bedrock"
|
||||||
|
| "openrouter"
|
||||||
|
| "deepseek"
|
||||||
|
| "siliconflow"
|
||||||
|
| "gateway"
|
||||||
|
|
||||||
|
// Individual model configuration
|
||||||
|
export interface ModelConfig {
|
||||||
|
id: string // UUID for this model
|
||||||
|
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
|
||||||
|
validated?: boolean // Has this model been validated
|
||||||
|
validationError?: string // Error message if validation failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider configuration
|
||||||
|
export interface ProviderConfig {
|
||||||
|
id: string // UUID for this provider config
|
||||||
|
provider: ProviderName
|
||||||
|
name?: string // Custom display name (e.g., "OpenAI Production")
|
||||||
|
apiKey: string
|
||||||
|
baseUrl?: string
|
||||||
|
// AWS Bedrock specific fields
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string // Optional, for temporary credentials
|
||||||
|
models: ModelConfig[]
|
||||||
|
validated?: boolean // Has API key been validated
|
||||||
|
}
|
||||||
|
|
||||||
|
// The complete multi-model configuration
|
||||||
|
export interface MultiModelConfig {
|
||||||
|
version: 1
|
||||||
|
providers: ProviderConfig[]
|
||||||
|
selectedModelId?: string // Currently selected model's UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flattened model for dropdown display
|
||||||
|
export interface FlattenedModel {
|
||||||
|
id: string // Model config UUID
|
||||||
|
modelId: string // Actual model ID
|
||||||
|
provider: ProviderName
|
||||||
|
providerLabel: string // Provider display name
|
||||||
|
apiKey: string
|
||||||
|
baseUrl?: string
|
||||||
|
// AWS Bedrock specific fields
|
||||||
|
awsAccessKeyId?: string
|
||||||
|
awsSecretAccessKey?: string
|
||||||
|
awsRegion?: string
|
||||||
|
awsSessionToken?: string
|
||||||
|
validated?: boolean // Has this model been validated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider metadata
|
||||||
|
export const PROVIDER_INFO: Record<
|
||||||
|
ProviderName,
|
||||||
|
{ label: string; defaultBaseUrl?: string }
|
||||||
|
> = {
|
||||||
|
openai: { label: "OpenAI" },
|
||||||
|
anthropic: {
|
||||||
|
label: "Anthropic",
|
||||||
|
defaultBaseUrl: "https://api.anthropic.com/v1",
|
||||||
|
},
|
||||||
|
google: { label: "Google" },
|
||||||
|
azure: { label: "Azure OpenAI" },
|
||||||
|
bedrock: { label: "Amazon Bedrock" },
|
||||||
|
openrouter: { label: "OpenRouter" },
|
||||||
|
deepseek: { label: "DeepSeek" },
|
||||||
|
siliconflow: {
|
||||||
|
label: "SiliconFlow",
|
||||||
|
defaultBaseUrl: "https://api.siliconflow.com/v1",
|
||||||
|
},
|
||||||
|
gateway: { label: "AI Gateway" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggested models per provider for quick add
|
||||||
|
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
||||||
|
openai: [
|
||||||
|
// GPT-4o series (latest)
|
||||||
|
"gpt-4o",
|
||||||
|
"gpt-4o-mini",
|
||||||
|
"gpt-4o-2024-11-20",
|
||||||
|
// GPT-4 Turbo
|
||||||
|
"gpt-4-turbo",
|
||||||
|
"gpt-4-turbo-preview",
|
||||||
|
// o1/o3 reasoning models
|
||||||
|
"o1",
|
||||||
|
"o1-mini",
|
||||||
|
"o1-preview",
|
||||||
|
"o3-mini",
|
||||||
|
// GPT-4
|
||||||
|
"gpt-4",
|
||||||
|
// GPT-3.5
|
||||||
|
"gpt-3.5-turbo",
|
||||||
|
],
|
||||||
|
anthropic: [
|
||||||
|
// Claude 4.5 series (latest)
|
||||||
|
"claude-opus-4-5-20250514",
|
||||||
|
"claude-sonnet-4-5-20250514",
|
||||||
|
// Claude 4 series
|
||||||
|
"claude-opus-4-20250514",
|
||||||
|
"claude-sonnet-4-20250514",
|
||||||
|
// Claude 3.7 series
|
||||||
|
"claude-3-7-sonnet-20250219",
|
||||||
|
// Claude 3.5 series
|
||||||
|
"claude-3-5-sonnet-20241022",
|
||||||
|
"claude-3-5-haiku-20241022",
|
||||||
|
// Claude 3 series
|
||||||
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-sonnet-20240229",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
|
],
|
||||||
|
google: [
|
||||||
|
// Gemini 2.5 series
|
||||||
|
"gemini-2.5-pro",
|
||||||
|
"gemini-2.5-flash",
|
||||||
|
"gemini-2.5-flash-preview-05-20",
|
||||||
|
// Gemini 2.0 series
|
||||||
|
"gemini-2.0-flash",
|
||||||
|
"gemini-2.0-flash-exp",
|
||||||
|
"gemini-2.0-flash-lite",
|
||||||
|
// Gemini 1.5 series
|
||||||
|
"gemini-1.5-pro",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
// Legacy
|
||||||
|
"gemini-pro",
|
||||||
|
],
|
||||||
|
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
|
||||||
|
bedrock: [
|
||||||
|
// Anthropic Claude
|
||||||
|
"anthropic.claude-opus-4-5-20250514-v1:0",
|
||||||
|
"anthropic.claude-sonnet-4-5-20250514-v1:0",
|
||||||
|
"anthropic.claude-opus-4-20250514-v1:0",
|
||||||
|
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||||
|
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||||
|
"anthropic.claude-3-opus-20240229-v1:0",
|
||||||
|
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||||
|
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||||
|
// Amazon Nova
|
||||||
|
"amazon.nova-pro-v1:0",
|
||||||
|
"amazon.nova-lite-v1:0",
|
||||||
|
"amazon.nova-micro-v1:0",
|
||||||
|
// Meta Llama
|
||||||
|
"meta.llama3-3-70b-instruct-v1:0",
|
||||||
|
"meta.llama3-1-405b-instruct-v1:0",
|
||||||
|
"meta.llama3-1-70b-instruct-v1:0",
|
||||||
|
// Mistral
|
||||||
|
"mistral.mistral-large-2411-v1:0",
|
||||||
|
"mistral.mistral-small-2503-v1:0",
|
||||||
|
],
|
||||||
|
openrouter: [
|
||||||
|
// Anthropic
|
||||||
|
"anthropic/claude-sonnet-4",
|
||||||
|
"anthropic/claude-opus-4",
|
||||||
|
"anthropic/claude-3.5-sonnet",
|
||||||
|
"anthropic/claude-3.5-haiku",
|
||||||
|
// OpenAI
|
||||||
|
"openai/gpt-4o",
|
||||||
|
"openai/gpt-4o-mini",
|
||||||
|
"openai/o1",
|
||||||
|
"openai/o3-mini",
|
||||||
|
// Google
|
||||||
|
"google/gemini-2.5-pro",
|
||||||
|
"google/gemini-2.5-flash",
|
||||||
|
"google/gemini-2.0-flash-exp:free",
|
||||||
|
// Meta Llama
|
||||||
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"meta-llama/llama-3.1-405b-instruct",
|
||||||
|
"meta-llama/llama-3.1-70b-instruct",
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek/deepseek-chat",
|
||||||
|
"deepseek/deepseek-r1",
|
||||||
|
// Qwen
|
||||||
|
"qwen/qwen-2.5-72b-instruct",
|
||||||
|
],
|
||||||
|
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||||
|
siliconflow: [
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek-ai/DeepSeek-V3",
|
||||||
|
"deepseek-ai/DeepSeek-R1",
|
||||||
|
"deepseek-ai/DeepSeek-V2.5",
|
||||||
|
// Qwen
|
||||||
|
"Qwen/Qwen2.5-72B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-32B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-Coder-32B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
|
"Qwen/Qwen2-VL-72B-Instruct",
|
||||||
|
],
|
||||||
|
gateway: [
|
||||||
|
"openai/gpt-4o",
|
||||||
|
"openai/gpt-4o-mini",
|
||||||
|
"anthropic/claude-sonnet-4-5",
|
||||||
|
"anthropic/claude-3-5-sonnet",
|
||||||
|
"google/gemini-2.0-flash",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate UUID
|
||||||
|
export function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty config
|
||||||
|
export function createEmptyConfig(): MultiModelConfig {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
providers: [],
|
||||||
|
selectedModelId: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new provider config
|
||||||
|
export function createProviderConfig(provider: ProviderName): ProviderConfig {
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
provider,
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: PROVIDER_INFO[provider].defaultBaseUrl,
|
||||||
|
models: [],
|
||||||
|
validated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new model config
|
||||||
|
export function createModelConfig(modelId: string): ModelConfig {
|
||||||
|
return {
|
||||||
|
id: generateId(),
|
||||||
|
modelId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all models as flattened list for dropdown
|
||||||
|
export function flattenModels(config: MultiModelConfig): FlattenedModel[] {
|
||||||
|
const models: FlattenedModel[] = []
|
||||||
|
|
||||||
|
for (const provider of config.providers) {
|
||||||
|
// Use custom name if provided, otherwise use default provider label
|
||||||
|
const providerLabel =
|
||||||
|
provider.name || PROVIDER_INFO[provider.provider].label
|
||||||
|
|
||||||
|
for (const model of provider.models) {
|
||||||
|
models.push({
|
||||||
|
id: model.id,
|
||||||
|
modelId: model.modelId,
|
||||||
|
provider: provider.provider,
|
||||||
|
providerLabel,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
baseUrl: provider.baseUrl,
|
||||||
|
// AWS Bedrock fields
|
||||||
|
awsAccessKeyId: provider.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey: provider.awsSecretAccessKey,
|
||||||
|
awsRegion: provider.awsRegion,
|
||||||
|
awsSessionToken: provider.awsSessionToken,
|
||||||
|
validated: model.validated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find model by ID
|
||||||
|
export function findModelById(
|
||||||
|
config: MultiModelConfig,
|
||||||
|
modelId: string,
|
||||||
|
): FlattenedModel | undefined {
|
||||||
|
return flattenModels(config).find((m) => m.id === modelId)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react"
|
import { useCallback } 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 { STORAGE_KEYS } from "@/lib/storage"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
|
||||||
export interface QuotaConfig {
|
export interface QuotaConfig {
|
||||||
dailyRequestLimit: number
|
dailyRequestLimit: number
|
||||||
@@ -11,179 +12,45 @@ export interface QuotaConfig {
|
|||||||
tpmLimit: number
|
tpmLimit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotaCheckResult {
|
|
||||||
allowed: boolean
|
|
||||||
remaining: number
|
|
||||||
used: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing request/token quotas and rate limiting.
|
* Hook for displaying quota limit toasts.
|
||||||
* Handles three types of limits:
|
* Server-side handles actual quota enforcement via DynamoDB.
|
||||||
* - Daily request limit
|
* This hook only provides UI feedback when limits are exceeded.
|
||||||
* - Daily token limit
|
|
||||||
* - Tokens per minute (TPM) rate limit
|
|
||||||
*
|
|
||||||
* Users with their own API key bypass all limits.
|
|
||||||
*/
|
*/
|
||||||
export function useQuotaManager(config: QuotaConfig): {
|
export function useQuotaManager(config: QuotaConfig): {
|
||||||
hasOwnApiKey: () => boolean
|
showQuotaLimitToast: (used?: number, limit?: number) => void
|
||||||
checkDailyLimit: () => QuotaCheckResult
|
showTokenLimitToast: (used?: number, limit?: number) => void
|
||||||
checkTokenLimit: () => QuotaCheckResult
|
showTPMLimitToast: (limit?: number) => void
|
||||||
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()
|
||||||
// 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(
|
||||||
toast.custom(
|
(used?: number, limit?: number) => {
|
||||||
(t) => (
|
toast.custom(
|
||||||
<QuotaLimitToast
|
(t) => (
|
||||||
used={dailyRequestLimit}
|
<QuotaLimitToast
|
||||||
limit={dailyRequestLimit}
|
used={used ?? dailyRequestLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
limit={limit ?? dailyRequestLimit}
|
||||||
/>
|
onDismiss={() => toast.dismiss(t)}
|
||||||
),
|
/>
|
||||||
{ duration: 15000 },
|
),
|
||||||
)
|
{ duration: 15000 },
|
||||||
}, [dailyRequestLimit])
|
)
|
||||||
|
},
|
||||||
// Check daily token limit
|
[dailyRequestLimit],
|
||||||
const checkTokenLimit = useMemo(
|
|
||||||
() =>
|
|
||||||
createQuotaChecker(
|
|
||||||
() => new Date().toDateString(),
|
|
||||||
STORAGE_KEYS.tokenDate,
|
|
||||||
STORAGE_KEYS.tokenCount,
|
|
||||||
dailyTokenLimit,
|
|
||||||
),
|
|
||||||
[createQuotaChecker, dailyTokenLimit],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Increment token count
|
|
||||||
const incrementTokenCount = useMemo(
|
|
||||||
() =>
|
|
||||||
createQuotaIncrementer(
|
|
||||||
() => new Date().toDateString(),
|
|
||||||
STORAGE_KEYS.tokenDate,
|
|
||||||
STORAGE_KEYS.tokenCount,
|
|
||||||
true, // Validate input tokens
|
|
||||||
),
|
|
||||||
[createQuotaIncrementer],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show token limit toast
|
// Show token limit toast
|
||||||
const showTokenLimitToast = useCallback(
|
const showTokenLimitToast = useCallback(
|
||||||
(used: number) => {
|
(used?: number, limit?: number) => {
|
||||||
toast.custom(
|
toast.custom(
|
||||||
(t) => (
|
(t) => (
|
||||||
<QuotaLimitToast
|
<QuotaLimitToast
|
||||||
type="token"
|
type="token"
|
||||||
used={used}
|
used={used ?? dailyTokenLimit}
|
||||||
limit={dailyTokenLimit}
|
limit={limit ?? dailyTokenLimit}
|
||||||
onDismiss={() => toast.dismiss(t)}
|
onDismiss={() => toast.dismiss(t)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -193,53 +60,24 @@ export function useQuotaManager(config: QuotaConfig): {
|
|||||||
[dailyTokenLimit],
|
[dailyTokenLimit],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check TPM (tokens per minute) limit
|
|
||||||
const checkTPMLimit = useMemo(
|
|
||||||
() =>
|
|
||||||
createQuotaChecker(
|
|
||||||
() => Math.floor(Date.now() / 60000).toString(),
|
|
||||||
STORAGE_KEYS.tpmMinute,
|
|
||||||
STORAGE_KEYS.tpmCount,
|
|
||||||
tpmLimit,
|
|
||||||
),
|
|
||||||
[createQuotaChecker, tpmLimit],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Increment TPM count
|
|
||||||
const incrementTPMCount = useMemo(
|
|
||||||
() =>
|
|
||||||
createQuotaIncrementer(
|
|
||||||
() => Math.floor(Date.now() / 60000).toString(),
|
|
||||||
STORAGE_KEYS.tpmMinute,
|
|
||||||
STORAGE_KEYS.tpmCount,
|
|
||||||
true, // Validate input tokens
|
|
||||||
),
|
|
||||||
[createQuotaIncrementer],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show TPM limit toast
|
// Show TPM limit toast
|
||||||
const showTPMLimitToast = useCallback(() => {
|
const showTPMLimitToast = useCallback(
|
||||||
const limitDisplay =
|
(limit?: number) => {
|
||||||
tpmLimit >= 1000 ? `${tpmLimit / 1000}k` : String(tpmLimit)
|
const effectiveLimit = limit ?? tpmLimit
|
||||||
toast.error(
|
const limitDisplay =
|
||||||
`Rate limit reached (${limitDisplay} tokens/min). Please wait 60 seconds before sending another request.`,
|
effectiveLimit >= 1000
|
||||||
{ duration: 8000 },
|
? `${effectiveLimit / 1000}k`
|
||||||
)
|
: String(effectiveLimit)
|
||||||
}, [tpmLimit])
|
const message = formatMessage(dict.quota.tpmMessageDetailed, {
|
||||||
|
limit: limitDisplay,
|
||||||
|
seconds: 60,
|
||||||
|
})
|
||||||
|
toast.error(message, { duration: 8000 })
|
||||||
|
},
|
||||||
|
[tpmLimit, dict],
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Check functions
|
|
||||||
hasOwnApiKey,
|
|
||||||
checkDailyLimit,
|
|
||||||
checkTokenLimit,
|
|
||||||
checkTPMLimit,
|
|
||||||
|
|
||||||
// Increment functions
|
|
||||||
incrementRequestCount,
|
|
||||||
incrementTokenCount,
|
|
||||||
incrementTPMCount,
|
|
||||||
|
|
||||||
// Toast functions
|
|
||||||
showQuotaLimitToast,
|
showQuotaLimitToast,
|
||||||
showTokenLimitToast,
|
showTokenLimitToast,
|
||||||
showTPMLimitToast,
|
showTPMLimitToast,
|
||||||
|
|||||||
198
lib/utils.ts
@@ -36,29 +36,73 @@ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
|||||||
/**
|
/**
|
||||||
* Check if mxCell XML output is complete (not truncated).
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
|
||||||
* Also handles function-calling wrapper tags that may be incorrectly included.
|
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||||
|
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||||
* @param xml - The XML string to check (can be undefined/null)
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
let trimmed = xml?.trim() || ""
|
const trimmed = xml?.trim() || ""
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
|
|
||||||
// Strip Anthropic function-calling wrapper tags if present
|
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||||
// These can leak into tool input due to AI SDK parsing issues
|
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||||
// Use loop because tags are nested: </mxCell></mxParameter></invoke>
|
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||||
let prev = ""
|
|
||||||
while (prev !== trimmed) {
|
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||||
prev = trimmed
|
|
||||||
trimmed = trimmed
|
// No valid ending found at all
|
||||||
.replace(/<\/mxParameter>\s*$/i, "")
|
if (lastValidEnd === -1) return false
|
||||||
.replace(/<\/invoke>\s*$/i, "")
|
|
||||||
.replace(/<\/antml:parameter>\s*$/i, "")
|
// Check what comes after the last valid ending
|
||||||
.replace(/<\/antml:invoke>\s*$/i, "")
|
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||||
.trim()
|
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||||
|
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||||
|
|
||||||
|
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||||
|
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||||
|
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only complete mxCell elements from partial/streaming XML.
|
||||||
|
* This allows progressive rendering during streaming by ignoring incomplete trailing elements.
|
||||||
|
* @param xml - The partial XML string (may contain incomplete trailing mxCell)
|
||||||
|
* @returns XML string containing only complete mxCell elements
|
||||||
|
*/
|
||||||
|
export function extractCompleteMxCells(xml: string | undefined | null): string {
|
||||||
|
if (!xml) return ""
|
||||||
|
|
||||||
|
const completeCells: Array<{ index: number; text: string }> = []
|
||||||
|
|
||||||
|
// Match self-closing mxCell tags: <mxCell ... />
|
||||||
|
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
|
||||||
|
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
|
||||||
|
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
|
||||||
|
|
||||||
|
// Find all self-closing mxCell elements
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = selfClosingPattern.exec(xml)) !== null) {
|
||||||
|
completeCells.push({ index: match.index, text: match[0] })
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -221,6 +265,21 @@ export function convertToLegalXml(xmlString: string): string {
|
|||||||
"&",
|
"&",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fix unescaped < and > in attribute values for XML parsing
|
||||||
|
// HTML content in value attributes (e.g., <b>Title</b>) needs to be escaped
|
||||||
|
// This is critical because DOMParser will fail on unescaped < > in attributes
|
||||||
|
if (/=\s*"[^"]*<[^"]*"/.test(cellContent)) {
|
||||||
|
cellContent = cellContent.replace(
|
||||||
|
/=\s*"([^"]*)"/g,
|
||||||
|
(_match, value) => {
|
||||||
|
const escaped = value
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
return `="${escaped}"`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Indent each line of the matched block for readability.
|
// Indent each line of the matched block for readability.
|
||||||
const formatted = cellContent
|
const formatted = cellContent
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -265,6 +324,20 @@ export function wrapWithMxFile(xml: string): string {
|
|||||||
content = xml.replace(/<\/?root>/g, "").trim()
|
content = xml.replace(/<\/?root>/g, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.)
|
||||||
|
// Find the last valid mxCell ending and remove everything after it
|
||||||
|
const lastSelfClose = content.lastIndexOf("/>")
|
||||||
|
const lastMxCellClose = content.lastIndexOf("</mxCell>")
|
||||||
|
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||||
|
if (lastValidEnd !== -1) {
|
||||||
|
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||||
|
const suffix = content.slice(lastValidEnd + endOffset)
|
||||||
|
// If suffix is only closing tags (wrapper tags), strip it
|
||||||
|
if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) {
|
||||||
|
content = content.slice(0, lastValidEnd + endOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
|
// Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully)
|
||||||
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
|
// Use flexible patterns that match both self-closing (/>) and non-self-closing (></mxCell>) formats
|
||||||
content = content
|
content = content
|
||||||
@@ -869,6 +942,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed CDATA wrapper")
|
fixes.push("Removed CDATA wrapper")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.)
|
||||||
|
// These are closing tags after the last valid mxCell that break XML parsing
|
||||||
|
const lastSelfClose = fixed.lastIndexOf("/>")
|
||||||
|
const lastMxCellClose = fixed.lastIndexOf("</mxCell>")
|
||||||
|
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||||
|
if (lastValidEnd !== -1) {
|
||||||
|
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||||
|
const suffix = fixed.slice(lastValidEnd + endOffset)
|
||||||
|
// If suffix contains only closing tags (wrapper tags) or whitespace, strip it
|
||||||
|
if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) {
|
||||||
|
fixed = fixed.slice(0, lastValidEnd + endOffset)
|
||||||
|
fixes.push("Stripped trailing LLM wrapper tags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
|
// 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML)
|
||||||
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i)
|
||||||
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) {
|
||||||
@@ -974,8 +1062,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed quotes around color values in style")
|
fixes.push("Removed quotes around color values in style")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fix unescaped < in attribute values
|
// 4. Fix unescaped < and > in attribute values
|
||||||
// This is tricky - we need to find < inside quoted attribute values
|
// < is required to be escaped, > is not strictly required but we escape for consistency
|
||||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||||
let attrMatch
|
let attrMatch
|
||||||
let hasUnescapedLt = false
|
let hasUnescapedLt = false
|
||||||
@@ -986,12 +1074,12 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasUnescapedLt) {
|
if (hasUnescapedLt) {
|
||||||
// Replace < with < inside attribute values
|
// Replace < and > with < and > inside attribute values
|
||||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||||
const escaped = value.replace(/</g, "<")
|
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
||||||
return `="${escaped}"`
|
return `="${escaped}"`
|
||||||
})
|
})
|
||||||
fixes.push("Escaped < characters in attribute values")
|
fixes.push("Escaped <> characters in attribute values")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Fix invalid character references (remove malformed ones)
|
// 5. Fix invalid character references (remove malformed ones)
|
||||||
@@ -1079,7 +1167,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
// 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first)
|
||||||
// Valid draw.io tags: mxfile, diagram, mxGraphModel, root, mxCell, mxGeometry, mxPoint, Array, Object
|
// IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values
|
||||||
|
// Tags like <b>, <br> inside value="<b>text</b>" should be preserved (they're HTML content)
|
||||||
const validDrawioTags = new Set([
|
const validDrawioTags = new Set([
|
||||||
"mxfile",
|
"mxfile",
|
||||||
"diagram",
|
"diagram",
|
||||||
@@ -1092,25 +1181,59 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
"Object",
|
"Object",
|
||||||
"mxRectangle",
|
"mxRectangle",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Helper: Check if a position is inside a quoted attribute value
|
||||||
|
// by counting unescaped quotes before that position
|
||||||
|
const isInsideQuotes = (str: string, pos: number): boolean => {
|
||||||
|
let inQuote = false
|
||||||
|
let quoteChar = ""
|
||||||
|
for (let i = 0; i < pos && i < str.length; i++) {
|
||||||
|
const c = str[i]
|
||||||
|
if (inQuote) {
|
||||||
|
if (c === quoteChar) inQuote = false
|
||||||
|
} else if (c === '"' || c === "'") {
|
||||||
|
// Check if this quote is part of an attribute (preceded by =)
|
||||||
|
// Look back for = sign
|
||||||
|
let j = i - 1
|
||||||
|
while (j >= 0 && /\s/.test(str[j])) j--
|
||||||
|
if (j >= 0 && str[j] === "=") {
|
||||||
|
inQuote = true
|
||||||
|
quoteChar = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inQuote
|
||||||
|
}
|
||||||
|
|
||||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||||
let foreignMatch
|
let foreignMatch
|
||||||
const foreignTags = new Set<string>()
|
const foreignTags = new Set<string>()
|
||||||
|
const foreignTagPositions: Array<{
|
||||||
|
tag: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}> = []
|
||||||
|
|
||||||
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) {
|
||||||
const tagName = foreignMatch[1]
|
const tagName = foreignMatch[1]
|
||||||
if (!validDrawioTags.has(tagName)) {
|
// Skip if this is a valid draw.io tag
|
||||||
foreignTags.add(tagName)
|
if (validDrawioTags.has(tagName)) continue
|
||||||
}
|
// Skip if this tag is inside a quoted attribute value
|
||||||
|
if (isInsideQuotes(fixed, foreignMatch.index)) continue
|
||||||
|
|
||||||
|
foreignTags.add(tagName)
|
||||||
|
foreignTagPositions.push({
|
||||||
|
tag: tagName,
|
||||||
|
start: foreignMatch.index,
|
||||||
|
end: foreignMatch.index + foreignMatch[0].length,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (foreignTags.size > 0) {
|
|
||||||
console.log(
|
if (foreignTagPositions.length > 0) {
|
||||||
"[autoFixXml] Step 8c: Found foreign tags:",
|
// Remove tags from end to start to preserve indices
|
||||||
Array.from(foreignTags),
|
foreignTagPositions.sort((a, b) => b.start - a.start)
|
||||||
)
|
for (const { start, end } of foreignTagPositions) {
|
||||||
for (const tag of foreignTags) {
|
fixed = fixed.slice(0, start) + fixed.slice(end)
|
||||||
// Remove opening tags (with or without attributes)
|
|
||||||
fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "")
|
|
||||||
// Remove closing tags
|
|
||||||
fixed = fixed.replace(new RegExp(`</${tag}>`, "gi"), "")
|
|
||||||
}
|
}
|
||||||
fixes.push(
|
fixes.push(
|
||||||
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
`Removed foreign tags: ${Array.from(foreignTags).join(", ")}`,
|
||||||
@@ -1161,6 +1284,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
|
|
||||||
// 10b. Remove extra closing tags (more closes than opens)
|
// 10b. Remove extra closing tags (more closes than opens)
|
||||||
// Need to properly count self-closing tags (they don't need closing tags)
|
// Need to properly count self-closing tags (they don't need closing tags)
|
||||||
|
// IMPORTANT: Only count tags at element level, NOT inside quoted attribute values
|
||||||
const tagCounts = new Map<
|
const tagCounts = new Map<
|
||||||
string,
|
string,
|
||||||
{ opens: number; closes: number; selfClosing: number }
|
{ opens: number; closes: number; selfClosing: number }
|
||||||
@@ -1169,12 +1293,18 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||||
let tagCountMatch
|
let tagCountMatch
|
||||||
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||||
|
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
|
||||||
|
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
|
||||||
|
|
||||||
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
||||||
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
||||||
const isClosing = tagPart.startsWith("/")
|
const isClosing = tagPart.startsWith("/")
|
||||||
const isSelfClosing = fullMatch.endsWith("/>")
|
const isSelfClosing = fullMatch.endsWith("/>")
|
||||||
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
const tagName = isClosing ? tagPart.slice(1) : tagPart
|
||||||
|
|
||||||
|
// Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming
|
||||||
|
if (!validDrawioTags.has(tagName)) continue
|
||||||
|
|
||||||
let counts = tagCounts.get(tagName)
|
let counts = tagCounts.get(tagName)
|
||||||
if (!counts) {
|
if (!counts) {
|
||||||
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
counts = { opens: 0, closes: 0, selfClosing: 0 }
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import packageJson from "./package.json"
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Support for subdirectory deployment (e.g., https://example.com/nextaidrawio)
|
||||||
|
// Set NEXT_PUBLIC_BASE_PATH environment variable to your subdirectory path (e.g., /nextaidrawio)
|
||||||
|
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
|
||||||
env: {
|
env: {
|
||||||
APP_VERSION: packageJson.version,
|
APP_VERSION: packageJson.version,
|
||||||
},
|
},
|
||||||
|
// Include instrumentation.ts in standalone build for Langfuse telemetry
|
||||||
|
outputFileTracingIncludes: {
|
||||||
|
"*": ["./instrumentation.ts"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
2287
package-lock.json
generated
35
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.5",
|
"version": "0.4.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -24,37 +24,41 @@
|
|||||||
"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": "^3.0.70",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
"@ai-sdk/anthropic": "^2.0.44",
|
"@ai-sdk/anthropic": "^3.0.0",
|
||||||
"@ai-sdk/azure": "^2.0.69",
|
"@ai-sdk/azure": "^3.0.0",
|
||||||
"@ai-sdk/deepseek": "^1.0.30",
|
"@ai-sdk/deepseek": "^2.0.0",
|
||||||
"@ai-sdk/gateway": "^2.0.21",
|
"@ai-sdk/gateway": "^3.0.0",
|
||||||
"@ai-sdk/google": "^2.0.0",
|
"@ai-sdk/google": "^3.0.0",
|
||||||
"@ai-sdk/openai": "^2.0.19",
|
"@ai-sdk/openai": "^3.0.0",
|
||||||
"@ai-sdk/react": "^2.0.107",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
|
"@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.2.3",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@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-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@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": "^5.0.89",
|
"ai": "^6.0.1",
|
||||||
"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",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
@@ -108,5 +112,10 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,7 +459,8 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
fixes.push("Removed quotes around color values in style")
|
fixes.push("Removed quotes around color values in style")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Fix unescaped < in attribute values
|
// 10. Fix unescaped < and > in attribute values
|
||||||
|
// < is required to be escaped, > is not strictly required but we escape for consistency
|
||||||
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g
|
||||||
let attrMatch
|
let attrMatch
|
||||||
let hasUnescapedLt = false
|
let hasUnescapedLt = false
|
||||||
@@ -471,10 +472,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
|||||||
}
|
}
|
||||||
if (hasUnescapedLt) {
|
if (hasUnescapedLt) {
|
||||||
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => {
|
||||||
const escaped = value.replace(/</g, "<")
|
const escaped = value.replace(/</g, "<").replace(/>/g, ">")
|
||||||
return `="${escaped}"`
|
return `="${escaped}"`
|
||||||
})
|
})
|
||||||
fixes.push("Escaped < characters in attribute values")
|
fixes.push("Escaped <> characters in attribute values")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Fix invalid hex character references
|
// 11. Fix invalid hex character references
|
||||||
@@ -903,24 +904,30 @@ export function validateAndFixXml(xml: string): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if mxCell XML output is complete (not truncated).
|
* Check if mxCell XML output is complete (not truncated).
|
||||||
|
* Uses a robust approach that handles any LLM provider's wrapper tags
|
||||||
|
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
|
||||||
* @param xml - The XML string to check (can be undefined/null)
|
* @param xml - The XML string to check (can be undefined/null)
|
||||||
* @returns true if XML appears complete, false if truncated or empty
|
* @returns true if XML appears complete, false if truncated or empty
|
||||||
*/
|
*/
|
||||||
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||||
let trimmed = xml?.trim() || ""
|
const trimmed = xml?.trim() || ""
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
|
|
||||||
// Strip wrapper tags if present
|
// Find position of last complete mxCell ending (either /> or </mxCell>)
|
||||||
let prev = ""
|
const lastSelfClose = trimmed.lastIndexOf("/>")
|
||||||
while (prev !== trimmed) {
|
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
|
||||||
prev = trimmed
|
|
||||||
trimmed = trimmed
|
|
||||||
.replace(/<\/mxParameter>\s*$/i, "")
|
|
||||||
.replace(/<\/invoke>\s*$/i, "")
|
|
||||||
.replace(/<\/antml:parameter>\s*$/i, "")
|
|
||||||
.replace(/<\/antml:invoke>\s*$/i, "")
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.endsWith("/>") || trimmed.endsWith("</mxCell>")
|
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
|
||||||
|
|
||||||
|
// No valid ending found at all
|
||||||
|
if (lastValidEnd === -1) return false
|
||||||
|
|
||||||
|
// Check what comes after the last valid ending
|
||||||
|
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||||
|
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||||
|
const suffix = trimmed.slice(lastValidEnd + endOffset)
|
||||||
|
|
||||||
|
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
|
||||||
|
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
|
||||||
|
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 111 KiB |