Compare commits

..

5 Commits

Author SHA1 Message Date
Dayuan Jiang
027cf2961e chore: remove About link from header and language switcher from about pages
- Remove About link and sponsor notice icon from chat panel header
- Remove language switcher (English | 中文 | 日本語) from all about pages
- Fix About link in settings dialog to use current language
- Remove unused sponsorTooltip translation key from all dictionaries
2025-12-30 22:38:35 +09:00
zhoujie0531
ca21a5bb27 feat: add EdgeOne Pages as AI provider (#456)
* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* feat: edit diagram

* feat: edit diagram

* feat: edit diagram

* feat: edit diagram

* feat: edit diagram

* feat: edit diagram

* feat: edit diagram

* feat: add edgeone provider

* feat: add edgeone provider

* feat: add edgeone provider

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: add cookie

* fix: add cookie

* fix: add cookie

* fix: add cookie

* fix: add cookie

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* fix: build error

* feat: validate

* feat: document link

---------

Co-authored-by: zoejiezhou <zoejiezhou@tencent.com>
2025-12-30 22:13:22 +09:00
broBinChen
ad80e9c6f5 i18n: add missing translations for chat UI components (#457)
* i18n: add missing translations for chat UI components

* i18n: add missing translations for chat components and toast messages
2025-12-30 20:52:57 +09:00
Biki Kalita
1ab8d260a2 fix: restore locale redirection using Next.js middleware (#462)
* fix: restore locale redirection using Next.js middleware

* fix: use proxy.ts instead of middleware.ts for Next.js 16

---------

Co-authored-by: Dayuan Jiang <jdy.toh@gmail.com>
2025-12-30 20:40:33 +09:00
Dayuan Jiang
2d62496f9f fix(edit_diagram): implement cascade delete for children and edges (#451)
* fix(edit_diagram): implement cascade delete for children and edges

- Add automatic cascade deletion when deleting a cell
- Recursively delete all child cells (parent attribute references)
- Delete all edges referencing deleted cells (source/target)
- Skip silently if cell already deleted (handles AI redundant ops)
- Update prompts to inform AI about cascade behavior

Fixes #450

* fix: add root cell protection and sync MCP server cascade delete

- Add protection for root cells '0' and '1' to prevent full diagram wipe
- Sync MCP server with main app's cascade delete logic
- Both lib/utils.ts and packages/mcp-server now have identical delete behavior

* chore(mcp): bump version to 0.1.9

* fix(cascade-delete): recursively collect edge children (labels)

- Change from cellsToDelete.add(edgeId) to collectDescendants(edgeId)
- Fixes orphaned edge labels causing draw.io to crash/clear canvas
- Edge labels (parent=edgeId) are now deleted with their parent edge
2025-12-30 00:03:30 +09:00
24 changed files with 635 additions and 155 deletions

3
.gitignore vendored
View File

@@ -63,3 +63,6 @@ push-via-ec2.sh
CLAUDE.md CLAUDE.md
.spec-workflow .spec-workflow
# edgeone
.edgeone

View File

@@ -237,6 +237,18 @@ npm run dev
## Deployment ## Deployment
### Deploy to EdgeOne Pages
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/).
Deploy by this button:
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details.
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
### Deploy on Vercel (Recommended) ### Deploy on Vercel (Recommended)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)

View File

@@ -72,28 +72,6 @@ export default function AboutCN() {
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 - AI驱动的图表创建工具 -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div> </div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -80,28 +80,6 @@ export default function AboutJA() {
AI搭載のダイアグラム作成ツール - AI搭載のダイアグラム作成ツール -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
</div>
</div> </div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -80,28 +80,6 @@ export default function About() {
AI-Powered Diagram Creation Tool - Chat, Draw, AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize Visualize
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm">
<Link
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span>
<Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div>
</div> </div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg"> <div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">

View File

@@ -244,9 +244,22 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === CACHE CHECK END === // === CACHE CHECK END ===
// Read client AI provider overrides from headers // Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
// For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path
if (provider === "edgeone" && !baseUrl) {
const origin = req.headers.get("origin") || new URL(req.url).origin
baseUrl = `${origin}/api/edgeai`
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
const clientOverrides = { const clientOverrides = {
provider: req.headers.get("x-ai-provider"), provider,
baseUrl: req.headers.get("x-ai-base-url"), baseUrl,
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 // AWS Bedrock credentials
@@ -254,6 +267,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"), awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"), awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"), awsSessionToken: req.headers.get("x-aws-session-token"),
// Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" &&
cookieHeader && {
headers: { cookie: cookieHeader },
}),
} }
// Read minimal style preference from header // Read minimal style preference from header

View File

@@ -121,7 +121,7 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
} else if (provider !== "ollama" && !apiKey) { } else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "API key is required" }, { valid: false, error: "API key is required" },
{ status: 400 }, { status: 400 },
@@ -225,6 +225,21 @@ export async function POST(req: Request) {
break break
} }
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
headers: {
cookie: cookieHeader,
},
})
model = edgeone.chat(modelId)
break
}
case "sglang": { case "sglang": {
// SGLang is OpenAI-compatible // SGLang is OpenAI-compatible
const sglang = createOpenAI({ const sglang = createOpenAI({

View File

@@ -27,6 +27,7 @@ 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 { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import {
applyDiagramOperations, applyDiagramOperations,
@@ -205,6 +206,7 @@ export function ChatMessageDisplay({
onEditMessage, onEditMessage,
status = "idle", status = "idle",
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("") const previousXML = useRef<string>("")
@@ -268,9 +270,7 @@ export function ChatMessageDisplay({
setTimeout(() => setCopiedMessageId(null), 2000) setTimeout(() => setCopiedMessageId(null), 2000)
} catch (fallbackErr) { } catch (fallbackErr) {
console.error("Failed to copy message:", fallbackErr) console.error("Failed to copy message:", fallbackErr)
toast.error( toast.error(dict.chat.failedToCopyDetail)
"Failed to copy message. Please copy manually or check clipboard permissions.",
)
setCopyFailedMessageId(messageId) setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000) setTimeout(() => setCopyFailedMessageId(null), 2000)
} finally { } finally {
@@ -304,7 +304,7 @@ export function ChatMessageDisplay({
}) })
} catch (error) { } catch (error) {
console.error("Failed to log feedback:", error) console.error("Failed to log feedback:", error)
toast.error("Failed to record your feedback. Please try again.") toast.error(dict.errors.failedToRecordFeedback)
// Revert optimistic UI update // Revert optimistic UI update
setFeedback((prev) => { setFeedback((prev) => {
const next = { ...prev } const next = { ...prev }
@@ -349,9 +349,7 @@ export function ChatMessageDisplay({
console.error( console.error(
"[ChatMessageDisplay] Malformed XML detected in final output", "[ChatMessageDisplay] Malformed XML detected in final output",
) )
toast.error( toast.error(dict.errors.malformedXml)
"AI generated invalid diagram XML. Please try regenerating.",
)
} }
return // Skip this update return // Skip this update
} }
@@ -402,9 +400,7 @@ export function ChatMessageDisplay({
"[ChatMessageDisplay] XML validation failed:", "[ChatMessageDisplay] XML validation failed:",
validation.error, validation.error,
) )
toast.error( toast.error(dict.errors.validationFailed)
"Diagram validation failed. Please try regenerating.",
)
} }
} catch (error) { } catch (error) {
console.error( console.error(
@@ -413,9 +409,7 @@ export function ChatMessageDisplay({
) )
// Only show toast if this is the final XML (not during streaming) // Only show toast if this is the final XML (not during streaming)
if (showToast) { if (showToast) {
toast.error( toast.error(dict.errors.failedToProcess)
"Failed to process diagram. Please try regenerating.",
)
} }
} }
} }
@@ -832,7 +826,10 @@ export function ChatMessageDisplay({
) )
}} }}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message" title={
dict.chat
.editMessage
}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
@@ -849,11 +846,13 @@ export function ChatMessageDisplay({
title={ title={
copiedMessageId === copiedMessageId ===
message.id message.id
? "Copied!" ? dict.chat.copied
: copyFailedMessageId === : copyFailedMessageId ===
message.id message.id
? "Failed to copy" ? dict.chat
: "Copy message" .failedToCopy
: dict.chat
.copyResponse
} }
> >
{copiedMessageId === {copiedMessageId ===
@@ -968,7 +967,7 @@ export function ChatMessageDisplay({
}} }}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
> >
Cancel {dict.common.cancel}
</button> </button>
<button <button
type="button" type="button"
@@ -990,7 +989,7 @@ export function ChatMessageDisplay({
disabled={!editText.trim()} disabled={!editText.trim()}
className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
> >
Save & Submit {dict.chat.saveAndSubmit}
</button> </button>
</div> </div>
</div> </div>
@@ -1123,7 +1122,8 @@ export function ChatMessageDisplay({
"user" && "user" &&
isLastUserMessage && isLastUserMessage &&
onEditMessage onEditMessage
? "Click to edit" ? dict.chat
.clickToEdit
: undefined : undefined
} }
> >
@@ -1325,8 +1325,8 @@ export function ChatMessageDisplay({
title={ title={
copiedMessageId === copiedMessageId ===
message.id message.id
? "Copied!" ? dict.chat.copied
: "Copy response" : dict.chat.copyResponse
} }
> >
{copiedMessageId === {copiedMessageId ===
@@ -1352,7 +1352,9 @@ export function ChatMessageDisplay({
) )
} }
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response" title={
dict.chat.regenerate
}
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</button> </button>
@@ -1374,7 +1376,7 @@ export function ChatMessageDisplay({
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50" : "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`} }`}
title="Good response" title={dict.chat.goodResponse}
> >
<ThumbsUp className="h-3.5 w-3.5" /> <ThumbsUp className="h-3.5 w-3.5" />
</button> </button>
@@ -1393,7 +1395,7 @@ export function ChatMessageDisplay({
? "text-red-600 bg-red-100" ? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50" : "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`} }`}
title="Bad response" title={dict.chat.badResponse}
> >
<ThumbsDown className="h-3.5 w-3.5" /> <ThumbsDown className="h-3.5 w-3.5" />
</button> </button>

View File

@@ -3,14 +3,12 @@
import { useChat } from "@ai-sdk/react" import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai" import { DefaultChatTransport } from "ai"
import { import {
AlertTriangle,
MessageSquarePlus, MessageSquarePlus,
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
Settings, Settings,
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link"
import type React from "react" import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
@@ -26,6 +24,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils"
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"
@@ -389,7 +388,9 @@ export default function ChatPanel({
MAX_CONTINUATION_RETRY_COUNT MAX_CONTINUATION_RETRY_COUNT
) { ) {
toast.error( toast.error(
`Continuation retry limit reached (${MAX_CONTINUATION_RETRY_COUNT}). The diagram may be too complex.`, formatMessage(dict.errors.continuationRetryLimit, {
max: MAX_CONTINUATION_RETRY_COUNT,
}),
) )
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
@@ -400,7 +401,9 @@ export default function ChatPanel({
// 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) {
toast.error( toast.error(
`Auto-retry limit reached (${MAX_AUTO_RETRY_COUNT}). Please try again manually.`, formatMessage(dict.errors.retryLimit, {
max: MAX_AUTO_RETRY_COUNT,
}),
) )
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
@@ -450,7 +453,7 @@ export default function ChatPanel({
// On complete failure, clear storage to allow recovery // On complete failure, clear storage to allow recovery
localStorage.removeItem(STORAGE_MESSAGES_KEY) localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error("Session data was corrupted. Starting fresh.") toast.error(dict.errors.sessionCorrupted)
} }
}, [setMessages]) }, [setMessages])
@@ -651,12 +654,10 @@ export default function ChatPanel({
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY) localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId) localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
toast.success("Started a fresh chat") toast.success(dict.dialogs.clearSuccess)
} catch (error) { } catch (error) {
console.error("Failed to clear localStorage:", error) console.error("Failed to clear localStorage:", error)
toast.warning( toast.warning(dict.errors.storageUpdateFailed)
"Chat cleared but browser storage could not be updated",
)
} }
setShowNewChatDialog(false) setShowNewChatDialog(false)
@@ -889,7 +890,7 @@ export default function ChatPanel({
return ( return (
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl"> <div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Show chat panel (Ctrl+B)" tooltipContent={dict.nav.showPanel}
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleVisibility} onClick={onToggleVisibility}
@@ -904,7 +905,7 @@ export default function ChatPanel({
transform: "rotate(180deg)", transform: "rotate(180deg)",
}} }}
> >
AI Chat {dict.nav.aiChat}
</div> </div>
</div> </div>
) )
@@ -949,32 +950,6 @@ export default function ChatPanel({
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
)}
{!isMobile && (
<Link
href="/about"
target="_blank"
rel="noopener noreferrer"
>
<ButtonWithTooltip
tooltipContent="Sponsored by ByteDance Doubao K2-thinking. 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" />
</ButtonWithTooltip>
</Link>
)}
</div> </div>
<div className="flex items-center gap-1 justify-end overflow-visible"> <div className="flex items-center gap-1 justify-end overflow-visible">
<ButtonWithTooltip <ButtonWithTooltip

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { wrapWithMxFile } from "@/lib/utils" import { wrapWithMxFile } from "@/lib/utils"
// Dev XML presets for streaming simulator // Dev XML presets for streaming simulator
@@ -142,6 +143,7 @@ export function DevXmlSimulator({
onDisplayChart, onDisplayChart,
onShowQuotaToast, onShowQuotaToast,
}: DevXmlSimulatorProps) { }: DevXmlSimulatorProps) {
const dict = useDictionary()
const [devXml, setDevXml] = useState("") const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false) const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1) const [devIntervalMs, setDevIntervalMs] = useState(1)
@@ -178,7 +180,7 @@ export function DevXmlSimulator({
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: "[Dev] Simulating XML streaming", text: dict.dev.simulatingMessage,
}, },
], ],
} }
@@ -228,7 +230,7 @@ export function DevXmlSimulator({
const lastMsg = updated[updated.length - 1] as any const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) { if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available" lastMsg.parts[0].state = "output-available"
lastMsg.parts[0].output = "Successfully displayed the diagram." lastMsg.parts[0].output = dict.dev.successMessage
lastMsg.parts[0].input = { xml } lastMsg.parts[0].input = { xml }
} }
return updated return updated
@@ -245,12 +247,12 @@ export function DevXmlSimulator({
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30"> <div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details> <details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium"> <summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
Dev: XML Streaming Simulator {dict.dev.title}
</summary> </summary>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
Preset: {dict.dev.preset}
</label> </label>
<select <select
onChange={(e) => { onChange={(e) => {
@@ -262,7 +264,7 @@ export function DevXmlSimulator({
defaultValue="" defaultValue=""
> >
<option value="" disabled> <option value="" disabled>
Select a preset... {dict.dev.selectPreset}
</option> </option>
{Object.keys(DEV_XML_PRESETS).map((name) => ( {Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}> <option key={name} value={name}>
@@ -275,19 +277,19 @@ export function DevXmlSimulator({
onClick={() => setDevXml("")} onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded" className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
> >
Clear {dict.dev.clear}
</button> </button>
</div> </div>
<textarea <textarea
value={devXml} value={devXml}
onChange={(e) => setDevXml(e.target.value)} onChange={(e) => setDevXml(e.target.value)}
placeholder="Paste mxCell XML here or select a preset..." placeholder={dict.dev.placeholder}
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background" 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-4">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
Interval: {dict.dev.interval}
</label> </label>
<input <input
type="range" type="range"
@@ -306,7 +308,7 @@ export function DevXmlSimulator({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
Chars: {dict.dev.chars}
</label> </label>
<input <input
type="number" type="number"
@@ -330,8 +332,8 @@ export function DevXmlSimulator({
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed" 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 {isSimulating
? "Streaming..." ? dict.dev.streaming
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`} : `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
</button> </button>
{isSimulating && ( {isSimulating && (
<button <button
@@ -341,7 +343,7 @@ export function DevXmlSimulator({
}} }}
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600" className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
> >
Stop {dict.dev.stop}
</button> </button>
)} )}
{onShowQuotaToast && ( {onShowQuotaToast && (
@@ -350,7 +352,7 @@ export function DevXmlSimulator({
onClick={onShowQuotaToast} onClick={onShowQuotaToast}
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600" className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
> >
Test Quota Toast {dict.dev.testQuotaToast}
</button> </button>
)} )}
</div> </div>

View File

@@ -78,6 +78,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
siliconflow: "siliconflow", siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible sglang: "openai", // SGLang is OpenAI-compatible
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance", doubao: "bytedance",
} }
@@ -277,6 +278,7 @@ export function ModelConfigDialog({
// Check credentials based on provider type // Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock" const isBedrock = selectedProvider.provider === "bedrock"
const isEdgeOne = selectedProvider.provider === "edgeone"
if (isBedrock) { if (isBedrock) {
if ( if (
!selectedProvider.awsAccessKeyId || !selectedProvider.awsAccessKeyId ||
@@ -285,7 +287,7 @@ export function ModelConfigDialog({
) { ) {
return return
} }
} else if (!selectedProvider.apiKey) { } else if (!isEdgeOne && !selectedProvider.apiKey) {
return return
} }
@@ -308,13 +310,18 @@ export function ModelConfigDialog({
setValidatingModelIndex(i) setValidatingModelIndex(i)
try { try {
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
const response = await fetch("/api/validate-model", { const response = await fetch("/api/validate-model", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
provider: selectedProvider.provider, provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey, apiKey: selectedProvider.apiKey,
baseUrl: selectedProvider.baseUrl, baseUrl,
modelId: model.modelId, modelId: model.modelId,
// AWS Bedrock credentials // AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId, awsAccessKeyId: selectedProvider.awsAccessKeyId,
@@ -322,7 +329,6 @@ export function ModelConfigDialog({
awsRegion: selectedProvider.awsRegion, awsRegion: selectedProvider.awsRegion,
}), }),
}) })
const data = await response.json() const data = await response.json()
if (data.valid) { if (data.valid) {
@@ -876,6 +882,63 @@ export function ModelConfigDialog({
)} )}
</div> </div>
</> </>
) : selectedProvider.provider ===
"edgeone" ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
variant={
validationStatus ===
"success"
? "outline"
: "default"
}
size="sm"
onClick={
handleValidate
}
disabled={
validationStatus ===
"validating"
}
className={cn(
"h-9 px-4",
validationStatus ===
"success" &&
"text-success border-success/30 bg-success-muted hover:bg-success-muted",
)}
>
{validationStatus ===
"validating" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validationStatus ===
"success" ? (
<>
<Check className="h-4 w-4 mr-1.5" />
{
dict
.modelConfig
.verified
}
</>
) : (
dict
.modelConfig
.test
)}
</Button>
{validationStatus ===
"error" &&
validationError && (
<p className="text-xs text-destructive flex items-center gap-1">
<X className="h-3 w-3" />
{
validationError
}
</p>
)}
</div>
</div>
) : ( ) : (
<> <>
{/* API Key */} {/* API Key */}

View File

@@ -48,6 +48,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
siliconflow: "siliconflow", siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel", gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance", doubao: "bytedance",
} }

View File

@@ -395,13 +395,13 @@ function SettingsContent({
<> <>
<span className="text-muted-foreground">·</span> <span className="text-muted-foreground">·</span>
<a <a
href="/about" href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1" className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
> >
<Info className="h-3 w-3" /> <Info className="h-3 w-3" />
About {dict.nav.about}
</a> </a>
</> </>
)} )}

View File

@@ -206,6 +206,19 @@ npm run dev
## 部署 ## 部署
### 部署到腾讯云EdgeOne Pages
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。
直接点击此按钮一键部署:
[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。
同时通过腾讯云EdgeOne Pages部署也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
### 部署到Vercel
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。 部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。 查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。

View File

@@ -206,6 +206,20 @@ npm run dev
## デプロイ ## デプロイ
### EdgeOne Pagesへのデプロイ
[Tencent EdgeOne Pages](https://pages.edgeone.ai/)を使用してワンクリックでデプロイできます。
このボタンでデプロイ:
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
詳細は[Tencent EdgeOne Pagesドキュメント](https://pages.edgeone.ai/document/deployment-overview)をご覧ください。
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
### Vercelへのデプロイ
Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。 Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成者による[Vercelプラットフォーム](https://vercel.com/new)を使用することです。
詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。 詳細は[Next.jsデプロイメントドキュメント](https://nextjs.org/docs/app/building-your-application/deploying)をご覧ください。

View File

@@ -0,0 +1,270 @@
/**
* EdgeOne Pages Edge Function for OpenAI-compatible Chat Completions API
*
* This endpoint provides an OpenAI-compatible API that can be used with
* AI SDK's createOpenAI({ baseURL: '/api/edgeai' })
*
* Uses EdgeOne Edge AI's AI.chatCompletions() which now supports native tool calling.
*/
import { z } from "zod"
// EdgeOne Pages global AI object
declare const AI: {
chatCompletions(options: {
model: string
messages: Array<{ role: string; content: string | null }>
stream?: boolean
max_tokens?: number
temperature?: number
tools?: any
tool_choice?: any
}): Promise<ReadableStream<Uint8Array> | any>
}
const messageItemSchema = z
.object({
role: z.enum(["user", "assistant", "system", "tool", "function"]),
content: z.string().nullable().optional(),
})
.passthrough()
const messageSchema = z
.object({
messages: z.array(messageItemSchema),
model: z.string().optional(),
stream: z.boolean().optional(),
tools: z.any().optional(),
tool_choice: z.any().optional(),
functions: z.any().optional(),
function_call: z.any().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
stop: z.union([z.string(), z.array(z.string())]).optional(),
response_format: z.any().optional(),
seed: z.number().optional(),
user: z.string().optional(),
n: z.number().int().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
parallel_tool_calls: z.boolean().optional(),
stream_options: z.any().optional(),
})
.passthrough()
// Model configuration
const ALLOWED_MODELS = [
"@tx/deepseek-ai/deepseek-v32",
"@tx/deepseek-ai/deepseek-r1-0528",
"@tx/deepseek-ai/deepseek-v3-0324",
]
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized response with CORS headers
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
}
export async function onRequest({ request, env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
}
request.headers.delete("accept-encoding")
try {
const json = await request.clone().json()
const parseResult = messageSchema.safeParse(json)
if (!parseResult.success) {
return createResponse(
{
error: {
message: parseResult.error.message,
type: "invalid_request_error",
},
},
400,
)
}
const { messages, model, stream, tools, tool_choice, ...extraParams } =
parseResult.data
// Validate messages
const userMessages = messages.filter(
(message) => message.role === "user",
)
if (!userMessages.length) {
return createResponse(
{
error: {
message: "No user message found",
type: "invalid_request_error",
},
},
400,
)
}
// Resolve model
const requestedModel = model || ALLOWED_MODELS[0]
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
if (!ALLOWED_MODELS.includes(selectedModel)) {
return createResponse(
{
error: {
message: `Invalid model: ${requestedModel}.`,
type: "invalid_request_error",
},
},
429,
)
}
console.log(
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
)
try {
const isStream = !!stream
// Non-streaming: return mock response for validation
// AI.chatCompletions doesn't support non-streaming mode
if (!isStream) {
const mockResponse = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: selectedModel,
choices: [
{
index: 0,
message: {
role: "assistant",
content: "OK",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 1,
total_tokens: 11,
},
}
return createResponse(mockResponse)
}
// Build AI.chatCompletions options for streaming
const aiOptions: any = {
...extraParams,
model: selectedModel,
messages,
stream: true,
}
// Add tools if provided
if (tools && tools.length > 0) {
aiOptions.tools = tools
}
if (tool_choice !== undefined) {
aiOptions.tool_choice = tool_choice
}
const aiResponse = await AI.chatCompletions(aiOptions)
// Streaming response
return new Response(aiResponse, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {
// Handle EdgeOne specific errors
try {
const message = JSON.parse(error.message)
if (message.code === 14020) {
return createResponse(
{
error: {
message:
"The daily public quota has been exhausted. After deployment, you can enjoy a personal daily exclusive quota.",
type: "rate_limit_error",
},
},
429,
)
}
return createResponse(
{ error: { message: error.message, type: "api_error" } },
500,
)
} catch {
// Not a JSON error message
}
console.error("[EdgeOne] AI error:", error.message)
return createResponse(
{
error: {
message: error.message || "AI service error",
type: "api_error",
},
},
500,
)
}
} catch (error: any) {
console.error("[EdgeOne] Request error:", error.message)
return createResponse(
{
error: {
message: "Request processing failed",
type: "server_error",
details: error.message,
},
},
500,
)
}
}

5
edgeone.json Normal file
View File

@@ -0,0 +1,5 @@
{
"nodeFunctionsConfig": {
"maxDuration": 120
}
}

View File

@@ -21,6 +21,7 @@ export type ProviderName =
| "siliconflow" | "siliconflow"
| "sglang" | "sglang"
| "gateway" | "gateway"
| "edgeone"
| "doubao" | "doubao"
interface ModelConfig { interface ModelConfig {
@@ -40,6 +41,8 @@ export interface ClientOverrides {
awsSecretAccessKey?: string | null awsSecretAccessKey?: string | null
awsRegion?: string | null awsRegion?: string | null
awsSessionToken?: string | null awsSessionToken?: string | null
// Custom headers (e.g., for EdgeOne cookie auth)
headers?: Record<string, string>
} }
// Providers that can be used with client-provided API keys // Providers that can be used with client-provided API keys
@@ -54,6 +57,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"siliconflow", "siliconflow",
"sglang", "sglang",
"gateway", "gateway",
"edgeone",
"doubao", "doubao",
] ]
@@ -375,6 +379,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY", sglang: "SGLANG_API_KEY",
gateway: "AI_GATEWAY_API_KEY", gateway: "AI_GATEWAY_API_KEY",
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
doubao: "DOUBAO_API_KEY", doubao: "DOUBAO_API_KEY",
} }
@@ -463,7 +468,12 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
// If a custom baseUrl is provided, an API key MUST also be provided. // If a custom baseUrl is provided, an API key MUST also be provided.
// This prevents attackers from redirecting server API keys to malicious endpoints. // This prevents attackers from redirecting server API keys to malicious endpoints.
if (overrides?.baseUrl && !overrides?.apiKey) { // Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
if (
overrides?.baseUrl &&
!overrides?.apiKey &&
overrides?.provider !== "edgeone"
) {
throw new Error( throw new Error(
`API key is required when using a custom base URL. ` + `API key is required when using a custom base URL. ` +
`Please provide your own API key in Settings.`, `Please provide your own API key in Settings.`,
@@ -840,6 +850,21 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "edgeone": {
// EdgeOne Pages Edge AI - uses OpenAI-compatible API
// AI SDK appends /chat/completions to baseURL
// /api/edgeai + /chat/completions = /api/edgeai/chat/completions
const baseURL = overrides?.baseUrl || "/api/edgeai"
const edgeoneProvider = createOpenAI({
apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key
baseURL,
// Pass cookies for EdgeOne Pages authentication (eo_token, eo_time)
...(overrides?.headers && { headers: overrides.headers }),
})
model = edgeoneProvider.chat(modelId)
break
}
case "doubao": { case "doubao": {
const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY const apiKey = overrides?.apiKey || process.env.DOUBAO_API_KEY
const baseURL = const baseURL =
@@ -856,7 +881,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, doubao`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao`,
) )
} }

View File

@@ -46,6 +46,7 @@
"copyResponse": "Copy response", "copyResponse": "Copy response",
"copied": "Copied!", "copied": "Copied!",
"failedToCopy": "Failed to copy", "failedToCopy": "Failed to copy",
"failedToCopyDetail": "Failed to copy message. Please copy manually or check clipboard permissions.",
"goodResponse": "Good response", "goodResponse": "Good response",
"badResponse": "Bad response", "badResponse": "Bad response",
"clickToEdit": "Click to edit", "clickToEdit": "Click to edit",
@@ -141,6 +142,7 @@
"invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.", "invalidAccessCode": "Invalid or missing access code. Please configure it in Settings.",
"networkError": "Network error. Please check your connection.", "networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"validationFailed": "Diagram validation failed. Please try regenerating.", "validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.", "malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.", "failedToProcess": "Failed to process diagram. Please try regenerating.",
@@ -149,7 +151,9 @@
"failedToRestore": "Failed to restore from localStorage", "failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload", "failedToPersist": "Failed to persist state before unload",
"failedToExport": "Error fetching chart data", "failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image" "failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
}, },
"quota": { "quota": {
"dailyLimit": "Daily Quota Reached", "dailyLimit": "Daily Quota Reached",
@@ -186,6 +190,21 @@
"thoughtFor": "Thought for {duration} seconds", "thoughtFor": "Thought for {duration} seconds",
"thoughtBrief": "Thought for a few seconds" "thoughtBrief": "Thought for a few seconds"
}, },
"dev": {
"title": "Dev: XML Streaming Simulator",
"preset": "Preset:",
"selectPreset": "Select a preset...",
"clear": "Clear",
"placeholder": "Paste mxCell XML here or select a preset...",
"interval": "Interval:",
"chars": "Chars:",
"streaming": "Streaming...",
"simulate": "Simulate",
"stop": "Stop",
"testQuotaToast": "Test Quota Toast",
"simulatingMessage": "[Dev] Simulating XML streaming",
"successMessage": "Successfully displayed the diagram."
},
"about": { "about": {
"modelChange": "Model Change & Usage Limits", "modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)", "walletCrying": "(Or: Why My Wallet is Crying)",

View File

@@ -46,6 +46,7 @@
"copyResponse": "応答をコピー", "copyResponse": "応答をコピー",
"copied": "コピーしました!", "copied": "コピーしました!",
"failedToCopy": "コピーに失敗しました", "failedToCopy": "コピーに失敗しました",
"failedToCopyDetail": "メッセージのコピーに失敗しました。手動でコピーするか、クリップボードの権限を確認してください。",
"goodResponse": "良い応答", "goodResponse": "良い応答",
"badResponse": "悪い応答", "badResponse": "悪い応答",
"clickToEdit": "クリックして編集", "clickToEdit": "クリックして編集",
@@ -141,6 +142,7 @@
"invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。", "invalidAccessCode": "無効または欠落したアクセスコード。設定で入力してください。",
"networkError": "ネットワークエラー。接続を確認してください。", "networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。", "validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。", "malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。", "failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
@@ -149,7 +151,9 @@
"failedToRestore": "localStorage からの復元に失敗しました", "failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました", "failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー", "failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー" "failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
}, },
"quota": { "quota": {
"dailyLimit": "1日の割当量に達しました", "dailyLimit": "1日の割当量に達しました",
@@ -186,6 +190,21 @@
"thoughtFor": "{duration} 秒考えました", "thoughtFor": "{duration} 秒考えました",
"thoughtBrief": "数秒考えました" "thoughtBrief": "数秒考えました"
}, },
"dev": {
"title": "開発XMLストリーミングシミュレーター",
"preset": "プリセット:",
"selectPreset": "プリセットを選択...",
"clear": "クリア",
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
"interval": "間隔:",
"chars": "文字:",
"streaming": "ストリーミング中...",
"simulate": "シミュレート",
"stop": "停止",
"testQuotaToast": "クォータトーストをテスト",
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
"successMessage": "ダイアグラムの表示に成功しました。"
},
"about": { "about": {
"modelChange": "モデル変更と利用制限について", "modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)", "walletCrying": "(別名:お財布が悲鳴を上げています)",

View File

@@ -46,6 +46,7 @@
"copyResponse": "复制响应", "copyResponse": "复制响应",
"copied": "已复制!", "copied": "已复制!",
"failedToCopy": "复制失败", "failedToCopy": "复制失败",
"failedToCopyDetail": "复制消息失败。请手动复制或检查剪贴板权限。",
"goodResponse": "有帮助", "goodResponse": "有帮助",
"badResponse": "无帮助", "badResponse": "无帮助",
"clickToEdit": "点击编辑", "clickToEdit": "点击编辑",
@@ -141,6 +142,7 @@
"invalidAccessCode": "无效或缺少访问码。请在设置中配置。", "invalidAccessCode": "无效或缺少访问码。请在设置中配置。",
"networkError": "网络错误。请检查您的连接。", "networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。", "retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"validationFailed": "图表验证失败。请尝试重新生成。", "validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。", "malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。", "failedToProcess": "无法处理图表。请尝试重新生成。",
@@ -149,7 +151,9 @@
"failedToRestore": "无法从 localStorage 恢复", "failedToRestore": "无法从 localStorage 恢复",
"failedToPersist": "卸载前无法持久化状态", "failedToPersist": "卸载前无法持久化状态",
"failedToExport": "获取图表数据时出错", "failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错" "failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
}, },
"quota": { "quota": {
"dailyLimit": "已达每日配额", "dailyLimit": "已达每日配额",
@@ -186,6 +190,21 @@
"thoughtFor": "思考了 {duration} 秒", "thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了几秒钟" "thoughtBrief": "思考了几秒钟"
}, },
"dev": {
"title": "开发XML 流式模拟器",
"preset": "预设:",
"selectPreset": "选择预设...",
"clear": "清除",
"placeholder": "在此粘贴 mxCell XML 或选择预设...",
"interval": "间隔:",
"chars": "字符:",
"streaming": "流式传输中...",
"simulate": "模拟",
"stop": "停止",
"testQuotaToast": "测试配额提示",
"simulatingMessage": "[开发] 模拟 XML 流式传输",
"successMessage": "成功显示图表。"
},
"about": { "about": {
"modelChange": "模型变更与用量限制", "modelChange": "模型变更与用量限制",
"walletCrying": "(别名:我的钱包顶不住了)", "walletCrying": "(别名:我的钱包顶不住了)",

View File

@@ -11,6 +11,7 @@ export type ProviderName =
| "siliconflow" | "siliconflow"
| "sglang" | "sglang"
| "gateway" | "gateway"
| "edgeone"
| "doubao" | "doubao"
// Individual model configuration // Individual model configuration
@@ -85,6 +86,7 @@ export const PROVIDER_INFO: Record<
defaultBaseUrl: "http://127.0.0.1:8000/v1", defaultBaseUrl: "http://127.0.0.1:8000/v1",
}, },
gateway: { label: "AI Gateway" }, gateway: { label: "AI Gateway" },
edgeone: { label: "EdgeOne Pages" },
doubao: { doubao: {
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
@@ -219,6 +221,7 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
"anthropic/claude-3-5-sonnet", "anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash", "google/gemini-2.0-flash",
], ],
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
doubao: [ doubao: [
// ByteDance Doubao models // ByteDance Doubao models
"doubao-1.5-thinking-pro-250415", "doubao-1.5-thinking-pro-250415",

View File

@@ -88,6 +88,11 @@
"unpdf": "^1.4.0", "unpdf": "^1.4.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"optionalDependencies": {
"lightningcss": "^1.30.2",
"lightningcss-linux-x64-gnu": "^1.30.2",
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18"
},
"lint-staged": { "lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [ "*.{js,ts,jsx,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched", "biome check --write --no-errors-on-unmatched",

63
proxy.ts Normal file
View File

@@ -0,0 +1,63 @@
import { match as matchLocale } from "@formatjs/intl-localematcher"
import Negotiator from "negotiator"
import type { NextRequest } from "next/server"
import { NextResponse } from "next/server"
import { i18n } from "./lib/i18n/config"
function getLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
// @ts-expect-error locales are readonly
const locales: string[] = i18n.locales
// Use negotiator and intl-localematcher to get best locale
const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
locales,
)
const locale = matchLocale(languages, locales, i18n.defaultLocale)
return locale
}
export function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Skip API routes, static files, and Next.js internals
if (
pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") ||
pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname)
) {
return
}
// Check if there is any supported locale in the pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) =>
!pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
)
// Redirect if there is no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// Redirect to localized path
return NextResponse.redirect(
new URL(
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
request.url,
),
)
}
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}