mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Compare commits
43 Commits
fix/upgrad
...
fix/kimi-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3a498306 | ||
|
|
2712f6d67c | ||
|
|
b84b156c8c | ||
|
|
c6eab07622 | ||
|
|
c382d3c0f4 | ||
|
|
e065d4b727 | ||
|
|
b2b9614404 | ||
|
|
1b96325fea | ||
|
|
19dd4e287f | ||
|
|
699767f392 | ||
|
|
feeb9ec0c5 | ||
|
|
f1a3044224 | ||
|
|
71a1d02e28 | ||
|
|
2d17963b2a | ||
|
|
3721b16d18 | ||
|
|
cbde35fb54 | ||
|
|
f098e557fc | ||
|
|
a245dcb150 | ||
|
|
f2f5ac907d | ||
|
|
0a79536002 | ||
|
|
f2c8fea58d | ||
|
|
31fffcc52d | ||
|
|
0a03ac9f45 | ||
|
|
6dad6ab147 | ||
|
|
8781520ebb | ||
|
|
67196225f0 | ||
|
|
09a5774cba | ||
|
|
fdd4be6463 | ||
|
|
f8a0ebd149 | ||
|
|
c9dd54dad7 | ||
|
|
1258f98478 | ||
|
|
f6f693ee8d | ||
|
|
9398117368 | ||
|
|
cb0c0fbcda | ||
|
|
e7c29fb410 | ||
|
|
f0dd199cd1 | ||
|
|
7656b64018 | ||
|
|
c3f88e54fe | ||
|
|
a55ef7adf9 | ||
|
|
984eaae04d | ||
|
|
0ed06360aa | ||
|
|
b0313eb2dc | ||
|
|
dc37ce7fb1 |
21
.github/workflows/electron-release.yml
vendored
21
.github/workflows/electron-release.yml
vendored
@@ -34,6 +34,15 @@ jobs:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
rm -rf public/drawio/META-INF
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -57,6 +66,16 @@ jobs:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||
mkdir -p public/drawio
|
||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||
rm -rf public/drawio/WEB-INF
|
||||
rm -rf public/drawio/META-INF
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -80,7 +99,7 @@ jobs:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
|
||||
project-slug: 'next-ai-draw-io'
|
||||
signing-policy-slug: 'test-signing'
|
||||
signing-policy-slug: 'release-signing'
|
||||
artifact-configuration-slug: 'windows-exe'
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ push-via-ec2.sh
|
||||
/dist-electron/
|
||||
/release/
|
||||
/electron-standalone/
|
||||
# Draw.io static files (downloaded during CI build)
|
||||
public/drawio/
|
||||
*.dmg
|
||||
*.exe
|
||||
*.AppImage
|
||||
|
||||
@@ -19,7 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
||||
|
||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||
|
||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
|
||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
@@ -202,7 +202,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
|
||||
## Multi-Provider Support
|
||||
|
||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -244,7 +244,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
|
||||
## Support & Contact
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "关于 - Next AI Draw.io",
|
||||
@@ -78,7 +78,7 @@ export default function AboutCN() {
|
||||
<p>
|
||||
好消息!感谢{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -277,7 +277,7 @@ export default function AboutCN() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -312,7 +312,7 @@ export default function AboutCN() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
特别感谢{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "概要 - Next AI Draw.io",
|
||||
@@ -86,7 +86,7 @@ export default function AboutJA() {
|
||||
<p>
|
||||
朗報です!
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -292,7 +292,7 @@ export default function AboutJA() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -327,7 +327,7 @@ export default function AboutJA() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About - Next AI Draw.io",
|
||||
@@ -87,7 +87,7 @@ export default function About() {
|
||||
Great news! Thanks to the generous
|
||||
sponsorship from{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline"
|
||||
@@ -311,7 +311,7 @@ export default function About() {
|
||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
@@ -348,7 +348,7 @@ export default function About() {
|
||||
<p className="text-gray-700 mb-4 font-semibold">
|
||||
Special thanks to{" "}
|
||||
<a
|
||||
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
|
||||
@@ -41,19 +41,24 @@ export async function generateMetadata({
|
||||
params: Promise<{ lang: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params
|
||||
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
|
||||
const lang = (
|
||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
||||
) as Locale
|
||||
|
||||
// Default to English metadata
|
||||
const titles: Record<Locale, string> = {
|
||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||
}
|
||||
|
||||
const descriptions: Record<Locale, string> = {
|
||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
||||
"zh-Hant":
|
||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,7 +85,14 @@ export async function generateMetadata({
|
||||
type: "website",
|
||||
url: "https://next-ai-drawio.jiang.jp",
|
||||
siteName: "Next AI Draw.io",
|
||||
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
|
||||
locale:
|
||||
lang === "zh"
|
||||
? "zh_CN"
|
||||
: lang === "zh-Hant"
|
||||
? "zh_HK"
|
||||
: lang === "ja"
|
||||
? "ja_JP"
|
||||
: "en_US",
|
||||
images: [
|
||||
{
|
||||
url: "/architecture.png",
|
||||
@@ -115,6 +127,7 @@ export async function generateMetadata({
|
||||
en: "/en",
|
||||
zh: "/zh",
|
||||
ja: "/ja",
|
||||
"zh-Hant": "/zh-Hant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
|
||||
const drawioBaseUrl =
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||
useDiagram()
|
||||
@@ -28,6 +25,10 @@ export default function Home() {
|
||||
const [darkMode, setDarkMode] = useState(false)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [isElectron, setIsElectron] = useState(false)
|
||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||
)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const isMobileRef = useRef(false)
|
||||
@@ -64,6 +65,17 @@ export default function Home() {
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
|
||||
// Detect Electron and use bundled draw.io files for offline use
|
||||
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
||||
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
||||
const electronDetected =
|
||||
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
||||
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
||||
if (electronDetected) {
|
||||
setIsElectron(true)
|
||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||
}
|
||||
|
||||
setIsLoaded(true)
|
||||
}, [pathname, router])
|
||||
|
||||
@@ -160,7 +172,7 @@ export default function Home() {
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
@@ -174,6 +186,10 @@ export default function Home() {
|
||||
noExitBtn: true,
|
||||
dark: darkMode,
|
||||
lang: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
offline: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -449,6 +449,7 @@ ${userInputText}
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
abortSignal: req.signal,
|
||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Link,
|
||||
Loader2,
|
||||
Send,
|
||||
Square,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
@@ -154,6 +155,7 @@ interface ChatInputProps {
|
||||
status: "submitted" | "streaming" | "ready" | "error"
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onStop?: () => void
|
||||
files?: File[]
|
||||
onFileChange?: (files: File[]) => void
|
||||
pdfData?: Map<
|
||||
@@ -170,7 +172,6 @@ interface ChatInputProps {
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
showUnvalidatedModels?: boolean
|
||||
onConfigureModels?: () => void
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
onFocused?: () => void
|
||||
@@ -183,6 +184,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onStop,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
@@ -194,7 +196,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
showUnvalidatedModels = false,
|
||||
onConfigureModels = () => {},
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
},
|
||||
@@ -550,29 +551,34 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={
|
||||
isDisabled ? dict.chat.sending : dict.chat.send
|
||||
}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
{dict.chat.send}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{(status === "streaming" || status === "submitted") &&
|
||||
onStop ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 rounded-xl shadow-sm"
|
||||
aria-label={dict.chat.stopGeneration}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={dict.chat.send}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
{dict.chat.send}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HistoryDialog
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
Copy,
|
||||
FileCode,
|
||||
FileText,
|
||||
Link,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import type { MutableRefObject } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
@@ -30,6 +30,7 @@ import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -59,20 +60,20 @@ function getCompleteOperations(
|
||||
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
|
||||
// Helper to split text content into regular text and file sections (PDF or text files)
|
||||
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
|
||||
interface TextSection {
|
||||
type: "text" | "file"
|
||||
type: "text" | "file" | "url"
|
||||
content: string
|
||||
filename?: string
|
||||
charCount?: number
|
||||
fileType?: "pdf" | "text"
|
||||
fileType?: "pdf" | "text" | "url"
|
||||
}
|
||||
|
||||
function splitTextIntoFileSections(text: string): TextSection[] {
|
||||
const sections: TextSection[] = []
|
||||
// Match [PDF: filename] or [File: filename] patterns
|
||||
// Match [PDF: filename], [File: filename], or [URL: url] patterns
|
||||
const filePattern =
|
||||
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
|
||||
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
@@ -83,28 +84,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
|
||||
sections.push({ type: "text", content: beforeText })
|
||||
}
|
||||
|
||||
// Add file section
|
||||
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
|
||||
// Add file/url section
|
||||
const sectionType = match[1].toLowerCase()
|
||||
const fileType =
|
||||
sectionType === "pdf"
|
||||
? "pdf"
|
||||
: sectionType === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
const filename = match[2].trim()
|
||||
const fileContent = match[3].trim()
|
||||
const content = match[3].trim()
|
||||
sections.push({
|
||||
type: "file",
|
||||
content: fileContent,
|
||||
type: sectionType === "url" ? "url" : "file",
|
||||
content: content,
|
||||
filename,
|
||||
charCount: fileContent.length,
|
||||
charCount: content.length,
|
||||
fileType,
|
||||
})
|
||||
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
// Add remaining text after last file section
|
||||
// Add remaining text after last section
|
||||
const remainingText = text.slice(lastIndex).trim()
|
||||
if (remainingText) {
|
||||
sections.push({ type: "text", content: remainingText })
|
||||
}
|
||||
|
||||
// If no file sections found, return original text
|
||||
// If no file/url sections found, return original text
|
||||
if (sections.length === 0) {
|
||||
sections.push({ type: "text", content: text })
|
||||
}
|
||||
@@ -123,8 +130,8 @@ const getMessageTextContent = (message: UIMessage): string => {
|
||||
// Get only the user's original text, excluding appended file content
|
||||
const getUserOriginalText = (message: UIMessage): string => {
|
||||
const fullText = getMessageTextContent(message)
|
||||
// Strip out [PDF: ...] and [File: ...] sections that were appended
|
||||
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
|
||||
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
|
||||
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||
return fullText.replace(filePattern, "").trim()
|
||||
}
|
||||
|
||||
@@ -1084,12 +1091,14 @@ export function ChatMessageDisplay({
|
||||
) => {
|
||||
if (
|
||||
section.type ===
|
||||
"file"
|
||||
"file" ||
|
||||
section.type ===
|
||||
"url"
|
||||
) {
|
||||
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
|
||||
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
|
||||
const isExpanded =
|
||||
expandedPdfSections[
|
||||
pdfKey
|
||||
sectionKey
|
||||
] ??
|
||||
false
|
||||
const charDisplay =
|
||||
@@ -1098,10 +1107,27 @@ export function ChatMessageDisplay({
|
||||
1000
|
||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||
: section.charCount
|
||||
|
||||
// Icon selector
|
||||
const Icon =
|
||||
section.fileType ===
|
||||
"pdf"
|
||||
? FileText
|
||||
: section.fileType ===
|
||||
"url"
|
||||
? Link
|
||||
: FileCode
|
||||
|
||||
const iconColor =
|
||||
section.fileType ===
|
||||
"pdf"
|
||||
? "text-red-500"
|
||||
: "text-blue-700"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
pdfKey
|
||||
sectionKey
|
||||
}
|
||||
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
|
||||
>
|
||||
@@ -1116,7 +1142,7 @@ export function ChatMessageDisplay({
|
||||
prev,
|
||||
) => ({
|
||||
...prev,
|
||||
[pdfKey]:
|
||||
[sectionKey]:
|
||||
!isExpanded,
|
||||
}),
|
||||
)
|
||||
@@ -1124,13 +1150,10 @@ export function ChatMessageDisplay({
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{section.fileType ===
|
||||
"pdf" ? (
|
||||
<FileText className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileCode className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
<Icon
|
||||
className={`h-4 w-4 ${iconColor}`}
|
||||
/>
|
||||
<span className="text-xs font-medium truncate max-w-[200px]">
|
||||
{
|
||||
section.filename
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import type React from "react"
|
||||
import {
|
||||
useCallback,
|
||||
@@ -22,6 +21,7 @@ import { flushSync } from "react-dom"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
@@ -131,6 +131,7 @@ export default function ChatPanel({
|
||||
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const urlSessionId = searchParams.get("session")
|
||||
|
||||
@@ -339,151 +340,155 @@ export default function ChatPanel({
|
||||
onValidationStateChange: handleValidationStateChange,
|
||||
})
|
||||
|
||||
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
|
||||
useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
}),
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
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
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
addToolOutput,
|
||||
status,
|
||||
error,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: getApiEndpoint("/api/chat"),
|
||||
}),
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
await handleToolCall({ toolCall }, addToolOutput)
|
||||
},
|
||||
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
|
||||
// Fallback to string matching
|
||||
if (error.message.includes("Daily request limit")) {
|
||||
quotaManager.showQuotaLimitToast()
|
||||
return
|
||||
}
|
||||
if (error.message.includes("Daily token limit")) {
|
||||
quotaManager.showTokenLimitToast()
|
||||
return
|
||||
}
|
||||
if (
|
||||
error.message.includes("Rate limit exceeded") ||
|
||||
error.message.includes("tokens per minute")
|
||||
) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return
|
||||
}
|
||||
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
}
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
// The server now handles detailed error messages, so we can display them directly.
|
||||
// But we still handle connection/network errors that happen before reaching the server.
|
||||
let friendlyMessage = error.message
|
||||
|
||||
// Simple check for network errors if message is generic
|
||||
if (friendlyMessage === "Failed to fetch") {
|
||||
friendlyMessage = "Network error. Please check your connection."
|
||||
}
|
||||
|
||||
// Truncated tool input error (model output limit too low)
|
||||
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
||||
friendlyMessage =
|
||||
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
||||
}
|
||||
|
||||
// Translate image not supported error
|
||||
if (
|
||||
friendlyMessage.includes("image content block") ||
|
||||
friendlyMessage.toLowerCase().includes("image_url")
|
||||
) {
|
||||
friendlyMessage = "This model doesn't support image input."
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: friendlyMessage,
|
||||
parts: [{ type: "text" as const, text: friendlyMessage }],
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings dialog to help user fix it
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
onFinish: () => {},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
const shouldRetry = hasToolErrors(
|
||||
messages as unknown as ChatMessage[],
|
||||
)
|
||||
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// Continuation mode: limited retries for truncation handling
|
||||
if (isInContinuationMode) {
|
||||
if (
|
||||
error.message.includes("Rate limit exceeded") ||
|
||||
error.message.includes("tokens per minute")
|
||||
continuationRetryCountRef.current >=
|
||||
MAX_CONTINUATION_RETRY_COUNT
|
||||
) {
|
||||
quotaManager.showTPMLimitToast()
|
||||
return
|
||||
}
|
||||
|
||||
// Silence access code error in console since it's handled by UI
|
||||
if (!error.message.includes("Invalid or missing access code")) {
|
||||
console.error("Chat error:", error)
|
||||
}
|
||||
|
||||
// Translate technical errors into user-friendly messages
|
||||
// The server now handles detailed error messages, so we can display them directly.
|
||||
// But we still handle connection/network errors that happen before reaching the server.
|
||||
let friendlyMessage = error.message
|
||||
|
||||
// Simple check for network errors if message is generic
|
||||
if (friendlyMessage === "Failed to fetch") {
|
||||
friendlyMessage =
|
||||
"Network error. Please check your connection."
|
||||
}
|
||||
|
||||
// Truncated tool input error (model output limit too low)
|
||||
if (friendlyMessage.includes("toolUse.input is invalid")) {
|
||||
friendlyMessage =
|
||||
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
|
||||
}
|
||||
|
||||
// Translate image not supported error
|
||||
if (
|
||||
friendlyMessage.includes("image content block") ||
|
||||
friendlyMessage.toLowerCase().includes("image_url")
|
||||
) {
|
||||
friendlyMessage = "This model doesn't support image input."
|
||||
}
|
||||
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: friendlyMessage,
|
||||
parts: [
|
||||
{ type: "text" as const, text: friendlyMessage },
|
||||
],
|
||||
}
|
||||
return [...currentMessages, errorMessage]
|
||||
})
|
||||
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings dialog to help user fix it
|
||||
setShowSettingsDialog(true)
|
||||
}
|
||||
},
|
||||
onFinish: () => {},
|
||||
sendAutomaticallyWhen: ({ messages }) => {
|
||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||
|
||||
const shouldRetry = hasToolErrors(
|
||||
messages as unknown as ChatMessage[],
|
||||
)
|
||||
|
||||
if (!shouldRetry) {
|
||||
// No error, reset retry count and clear state
|
||||
autoRetryCountRef.current = 0
|
||||
toast.error(
|
||||
formatMessage(dict.errors.continuationRetryLimit, {
|
||||
max: MAX_CONTINUATION_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// Continuation mode: limited retries for truncation handling
|
||||
if (isInContinuationMode) {
|
||||
if (
|
||||
continuationRetryCountRef.current >=
|
||||
MAX_CONTINUATION_RETRY_COUNT
|
||||
) {
|
||||
toast.error(
|
||||
formatMessage(dict.errors.continuationRetryLimit, {
|
||||
max: MAX_CONTINUATION_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
continuationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
continuationRetryCountRef.current++
|
||||
} else {
|
||||
// Regular error: check retry count limit
|
||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||
toast.error(
|
||||
formatMessage(dict.errors.retryLimit, {
|
||||
max: MAX_AUTO_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
// Increment retry count for actual errors
|
||||
autoRetryCountRef.current++
|
||||
continuationRetryCountRef.current++
|
||||
} else {
|
||||
// Regular error: check retry count limit
|
||||
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
|
||||
toast.error(
|
||||
formatMessage(dict.errors.retryLimit, {
|
||||
max: MAX_AUTO_RETRY_COUNT,
|
||||
}),
|
||||
)
|
||||
autoRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
return false
|
||||
}
|
||||
// Increment retry count for actual errors
|
||||
autoRetryCountRef.current++
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
|
||||
useEffect(() => {
|
||||
@@ -901,10 +906,10 @@ export default function ChatPanel({
|
||||
if (result.wasCurrentSession) {
|
||||
// Deleted current session - clear UI and URL
|
||||
syncUIWithSession(null)
|
||||
router.replace(window.location.pathname, { scroll: false })
|
||||
router.replace(pathname, { scroll: false })
|
||||
}
|
||||
},
|
||||
[sessionManager, syncUIWithSession, router],
|
||||
[sessionManager, syncUIWithSession, router, pathname],
|
||||
)
|
||||
|
||||
const handleNewChat = useCallback(async () => {
|
||||
@@ -937,7 +942,7 @@ export default function ChatPanel({
|
||||
toast.success(dict.dialogs.clearSuccess)
|
||||
|
||||
// Clear URL param to show blank state
|
||||
router.replace(window.location.pathname, { scroll: false })
|
||||
router.replace(pathname, { scroll: false })
|
||||
|
||||
// After starting a fresh chat, move focus back to the chat input
|
||||
setShouldFocusInput(true)
|
||||
@@ -952,6 +957,7 @@ export default function ChatPanel({
|
||||
dict.dialogs.clearSuccess,
|
||||
buildSessionData,
|
||||
setDiagramHistory,
|
||||
pathname,
|
||||
])
|
||||
|
||||
const handleInputChange = (
|
||||
@@ -991,6 +997,29 @@ export default function ChatPanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const toolParts = lastMessage?.parts?.filter(
|
||||
(part: any) =>
|
||||
part.type?.startsWith("tool-") &&
|
||||
part.state === "input-streaming",
|
||||
)
|
||||
|
||||
toolParts?.forEach((part: any) => {
|
||||
if (part.toolCallId) {
|
||||
addToolOutput({
|
||||
tool: part.type.replace("tool-", ""),
|
||||
toolCallId: part.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: "Stopped by user",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
stop()
|
||||
}, [messages, addToolOutput, stop])
|
||||
|
||||
// Send chat message with headers
|
||||
const sendChatMessage = (
|
||||
parts: any,
|
||||
@@ -1357,6 +1386,7 @@ export default function ChatPanel({
|
||||
status={status}
|
||||
onSubmit={onFormSubmit}
|
||||
onChange={handleInputChange}
|
||||
onStop={handleStop}
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
pdfData={pdfData}
|
||||
@@ -1368,7 +1398,6 @@ export default function ChatPanel({
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onModelSelect={modelConfig.setSelectedModelId}
|
||||
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
|
||||
onConfigureModels={() => setShowModelConfigDialog(true)}
|
||||
shouldFocus={shouldFocusInput}
|
||||
onFocused={() => setShouldFocusInput(false)}
|
||||
/>
|
||||
@@ -1385,6 +1414,7 @@ export default function ChatPanel({
|
||||
onMinimalStyleChange={setMinimalStyle}
|
||||
vlmValidationEnabled={vlmValidationEnabled}
|
||||
onVlmValidationChange={handleVlmValidationChange}
|
||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||
/>
|
||||
|
||||
<ModelConfigDialog
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
16
components/image-with-basepath.tsx
Normal file
16
components/image-with-basepath.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import NextImage, { type ImageProps } from "next/image"
|
||||
import { forwardRef } from "react"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
export default forwardRef<HTMLImageElement, ImageProps>(
|
||||
function Image(props, ref) {
|
||||
const src =
|
||||
typeof props.src === "string" &&
|
||||
props.src.startsWith("/") &&
|
||||
!props.src.startsWith("//")
|
||||
? getAssetUrl(props.src)
|
||||
: props.src
|
||||
|
||||
return <NextImage {...props} src={src} ref={ref} />
|
||||
},
|
||||
)
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
@@ -34,7 +33,6 @@ interface ModelSelectorProps {
|
||||
models: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
@@ -85,7 +83,6 @@ export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
@@ -126,9 +123,7 @@ export function ModelSelector({
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__configure__") {
|
||||
onConfigure()
|
||||
} else if (value === "__server_default__") {
|
||||
if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
} else {
|
||||
onSelect(value)
|
||||
@@ -386,20 +381,6 @@ export function ModelSelector({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Configure Option */}
|
||||
<ModelSelectorSeparator />
|
||||
<ModelSelectorGroup>
|
||||
<ModelSelectorItem
|
||||
value="__configure__"
|
||||
onSelect={handleSelect}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<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">
|
||||
{showUnvalidatedModels
|
||||
|
||||
@@ -79,7 +79,7 @@ export function QuotaLimitToast({
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
||||
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
||||
link: "https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
@@ -56,6 +56,7 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
"zh-Hant": "繁體中文",
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
@@ -69,6 +70,7 @@ interface SettingsDialogProps {
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
vlmValidationEnabled?: boolean
|
||||
onVlmValidationChange?: (value: boolean) => void
|
||||
onOpenModelConfig?: () => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
@@ -92,6 +94,7 @@ function SettingsContent({
|
||||
onMinimalStyleChange = () => {},
|
||||
vlmValidationEnabled = false,
|
||||
onVlmValidationChange = () => {},
|
||||
onOpenModelConfig,
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
@@ -283,6 +286,27 @@ function SettingsContent({
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{/* API Keys & Models */}
|
||||
{onOpenModelConfig && (
|
||||
<SettingItem
|
||||
label={dict.settings.apiKeysModels}
|
||||
description={dict.settings.apiKeysModelsDescription}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenModelConfig()
|
||||
}}
|
||||
aria-label={dict.settings.apiKeysModels}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Access Code (conditional) */}
|
||||
{accessCodeRequired && (
|
||||
<div className="py-4 first:pt-0 space-y-3">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||
|
||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
@@ -195,7 +195,7 @@ npm run dev
|
||||
|
||||
## 多提供商支持
|
||||
|
||||
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock(默认)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -236,7 +236,7 @@ npm run dev
|
||||
|
||||
## 支持与联系
|
||||
|
||||
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||
|
||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
### 豆包 (字节跳动火山引擎)
|
||||
|
||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
|
||||
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||
|
||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
||||
|
||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
|
||||
@@ -196,7 +196,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
||||
|
||||
## マルチプロバイダーサポート
|
||||
|
||||
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
||||
- AWS Bedrock(デフォルト)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
@@ -237,7 +237,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
## サポート&お問い合わせ
|
||||
|
||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
||||
|
||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
### Doubao (ByteDance Volcengine)
|
||||
|
||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
||||
|
||||
```bash
|
||||
DOUBAO_API_KEY=your_api_key
|
||||
|
||||
@@ -37,10 +37,11 @@ mac:
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
hardenedRuntime: true
|
||||
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
|
||||
# to properly sign nested bundles with --deep flag for bundled draw.io files
|
||||
identity: null
|
||||
hardenedRuntime: false
|
||||
gatekeeperAssess: false
|
||||
entitlements: resources/entitlements.mac.plist
|
||||
entitlementsInherit: resources/entitlements.mac.plist
|
||||
|
||||
dmg:
|
||||
contents:
|
||||
|
||||
4
electron/electron.d.ts
vendored
4
electron/electron.d.ts
vendored
@@ -69,7 +69,9 @@ declare global {
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||
/** Get user's preferred locale */
|
||||
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
|
||||
getUserLocale: () => Promise<
|
||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ interface ConfigPresetsFile {
|
||||
version: 1
|
||||
currentPresetId: string | null
|
||||
presets: ConfigPreset[]
|
||||
userLocale?: "en" | "zh" | "ja"
|
||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME = "config-presets.json"
|
||||
@@ -470,7 +470,7 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
||||
* Get user's preferred locale from config
|
||||
* Returns undefined if not set
|
||||
*/
|
||||
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
||||
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
||||
const data = loadPresets()
|
||||
return data.userLocale
|
||||
}
|
||||
@@ -478,7 +478,9 @@ export function getUserLocale(): "en" | "zh" | "ja" | undefined {
|
||||
/**
|
||||
* Set user's preferred locale in config
|
||||
*/
|
||||
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
|
||||
export function setUserLocale(
|
||||
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
||||
): void {
|
||||
const data = loadPresets()
|
||||
data.userLocale = locale === null ? undefined : locale
|
||||
savePresets(data)
|
||||
|
||||
@@ -263,12 +263,12 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja"].includes(locale)) {
|
||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
}
|
||||
|
||||
try {
|
||||
setUserLocale(locale as "en" | "zh" | "ja")
|
||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
||||
// Rebuild the menu to reflect the new locale
|
||||
rebuildAppMenu()
|
||||
return { success: true }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { getUserLocale } from "./config-manager"
|
||||
|
||||
export type MenuLocale = "en" | "zh" | "ja"
|
||||
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
||||
|
||||
export interface MenuTranslations {
|
||||
// App menu (macOS only)
|
||||
@@ -119,6 +119,34 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
documentation: "ドキュメント",
|
||||
reportIssue: "問題を報告",
|
||||
},
|
||||
|
||||
"zh-Hant": {
|
||||
// App menu
|
||||
settings: "設定...",
|
||||
|
||||
// File menu
|
||||
file: "檔案",
|
||||
|
||||
// Edit menu
|
||||
edit: "編輯",
|
||||
|
||||
// View menu
|
||||
view: "檢視",
|
||||
|
||||
// Configuration menu
|
||||
configuration: "配置",
|
||||
switchPreset: "切換預設",
|
||||
managePresets: "管理預設...",
|
||||
addConfigurationPreset: "新增配置預設...",
|
||||
|
||||
// Window menu
|
||||
window: "視窗",
|
||||
|
||||
// Help menu
|
||||
help: "說明",
|
||||
documentation: "文件",
|
||||
reportIssue: "回報問題",
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +154,16 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
* Falls back to English if locale is not supported
|
||||
*/
|
||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
||||
// Check for zh-Hant before normalizing
|
||||
if (
|
||||
locale === "zh-Hant" ||
|
||||
locale.toLowerCase().startsWith("zh-hant") ||
|
||||
locale.toLowerCase().startsWith("zh-tw") ||
|
||||
locale.toLowerCase().startsWith("zh-hk")
|
||||
) {
|
||||
return translations["zh-Hant"]
|
||||
}
|
||||
|
||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
||||
const normalized = locale.toLowerCase().split("-")[0]
|
||||
|
||||
@@ -136,10 +174,21 @@ export function getMenuTranslations(locale: string): MenuTranslations {
|
||||
|
||||
/**
|
||||
* Detect system locale from Electron app
|
||||
* Returns one of: "en", "zh", "ja"
|
||||
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
||||
*/
|
||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
||||
const normalized = appLocale.toLowerCase().split("-")[0]
|
||||
const lower = appLocale.toLowerCase()
|
||||
|
||||
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||
if (
|
||||
lower.startsWith("zh-hant") ||
|
||||
lower.startsWith("zh-tw") ||
|
||||
lower.startsWith("zh-hk")
|
||||
) {
|
||||
return "zh-Hant"
|
||||
}
|
||||
|
||||
const normalized = lower.split("-")[0]
|
||||
|
||||
if (normalized === "zh") return "zh"
|
||||
if (normalized === "ja") return "ja"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
@@ -147,7 +148,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
fetch("/api/server-models")
|
||||
fetch(getApiEndpoint("/api/server-models"))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ClientOverrides {
|
||||
baseUrlEnv?: string
|
||||
}
|
||||
|
||||
// Providers that can be used with client-provided API keys
|
||||
// Providers that can be selected from client settings
|
||||
const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
@@ -53,6 +53,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"sglang",
|
||||
"gateway",
|
||||
"edgeone",
|
||||
"ollama",
|
||||
"doubao",
|
||||
"modelscope",
|
||||
]
|
||||
@@ -572,12 +573,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||
// If a custom baseUrl is provided, an API key MUST also be provided.
|
||||
// This prevents attackers from redirecting server API keys to malicious endpoints.
|
||||
// Exception: EdgeOne provider doesn't require API key (uses Edge AI runtime)
|
||||
// Exception: EdgeOne and Ollama providers don't require API keys
|
||||
if (
|
||||
overrides?.baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!(overrides?.provider === "vertexai" && overrides?.vertexApiKey) &&
|
||||
overrides?.provider !== "edgeone"
|
||||
overrides?.provider !== "edgeone" &&
|
||||
overrides?.provider !== "ollama"
|
||||
) {
|
||||
throw new Error(
|
||||
`API key is required when using a custom base URL. ` +
|
||||
@@ -836,16 +838,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama":
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
const customOllama = createOllama({
|
||||
baseURL: process.env.OLLAMA_BASE_URL,
|
||||
})
|
||||
case "ollama": {
|
||||
const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL
|
||||
if (baseURL) {
|
||||
const customOllama = createOllama({ baseURL })
|
||||
model = customOllama(modelId)
|
||||
} else {
|
||||
model = ollama(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "openrouter": {
|
||||
const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY")
|
||||
@@ -1159,8 +1161,15 @@ export function supportsImageInput(modelId: string): boolean {
|
||||
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||
|
||||
// Models that DON'T support image/vision input (unless vision variant)
|
||||
// Kimi K2 models don't support images
|
||||
if (lowerModelId.includes("kimi") && !hasVisionIndicator) {
|
||||
// Kimi K2 doesn't support images, but K2.5 does
|
||||
// Only block kimi-k2 specifically, not other Kimi models
|
||||
if (
|
||||
(lowerModelId.includes("kimi-k2") ||
|
||||
lowerModelId.includes("kimi_k2")) &&
|
||||
!hasVisionIndicator &&
|
||||
!lowerModelId.includes("2.5") &&
|
||||
!lowerModelId.includes("k2.5")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const i18n = {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "zh", "ja"],
|
||||
locales: ["en", "zh", "ja", "zh-Hant"],
|
||||
} as const
|
||||
|
||||
export type Locale = (typeof i18n)["locales"][number]
|
||||
|
||||
@@ -6,6 +6,8 @@ const dictionaries = {
|
||||
en: () => import("./dictionaries/en.json").then((m) => m.default),
|
||||
zh: () => import("./dictionaries/zh.json").then((m) => m.default),
|
||||
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
|
||||
"zh-Hant": () =>
|
||||
import("./dictionaries/zh-Hant.json").then((m) => m.default),
|
||||
}
|
||||
|
||||
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"chat": {
|
||||
"placeholder": "Describe your diagram or upload a file...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"stopGeneration": "Stop generation",
|
||||
"sendMessage": "Send message",
|
||||
"clearConversation": "Clear conversation",
|
||||
"diagramHistory": "Diagram history",
|
||||
@@ -77,6 +77,8 @@
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"description": "Configure your application settings.",
|
||||
"apiKeysModels": "API Keys & Models",
|
||||
"apiKeysModelsDescription": "Configure AI providers and API keys.",
|
||||
"accessCode": "Access Code",
|
||||
"accessCodePlaceholder": "Enter access code",
|
||||
"accessCodeDescription": "Required to use this application.",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"chat": {
|
||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||
"send": "送信",
|
||||
"sending": "送信中...",
|
||||
"stopGeneration": "生成を停止",
|
||||
"sendMessage": "メッセージを送信",
|
||||
"clearConversation": "会話をクリア",
|
||||
"diagramHistory": "ダイアグラム履歴",
|
||||
@@ -77,6 +77,8 @@
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "アプリケーション設定を構成します。",
|
||||
"apiKeysModels": "API キーとモデル",
|
||||
"apiKeysModelsDescription": "AI プロバイダーと API キーを設定します。",
|
||||
"accessCode": "アクセスコード",
|
||||
"accessCodePlaceholder": "アクセスコードを入力",
|
||||
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
|
||||
|
||||
342
lib/i18n/dictionaries/zh-Hant.json
Normal file
342
lib/i18n/dictionaries/zh-Hant.json
Normal file
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"close": "關閉",
|
||||
"confirm": "確認",
|
||||
"clear": "清除",
|
||||
"edit": "編輯",
|
||||
"delete": "刪除",
|
||||
"loading": "載入中...",
|
||||
"new": "新建"
|
||||
},
|
||||
"nav": {
|
||||
"about": "關於",
|
||||
"editor": "編輯器",
|
||||
"newChat": "開始新對話",
|
||||
"github": "GitHub",
|
||||
"settings": "設定",
|
||||
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
|
||||
"showPanel": "顯示聊天面板 (Ctrl+B)",
|
||||
"aiChat": "AI 聊天"
|
||||
},
|
||||
"providers": {
|
||||
"useServerDefault": "使用伺服器預設值",
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"google": "Google",
|
||||
"azure": "Azure OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"deepseek": "DeepSeek",
|
||||
"siliconflow": "SiliconFlow",
|
||||
"modelscope": "ModelScope"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的圖表或上傳檔案...",
|
||||
"send": "傳送",
|
||||
"stopGeneration": "停止產生",
|
||||
"sendMessage": "傳送訊息",
|
||||
"clearConversation": "清除對話",
|
||||
"diagramHistory": "圖表歷史",
|
||||
"saveDiagram": "儲存圖表",
|
||||
"uploadFile": "上傳檔案(圖片、PDF、文字)",
|
||||
"minimalStyle": "簡約",
|
||||
"styledMode": "精緻",
|
||||
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
|
||||
"regenerate": "重新產生回應",
|
||||
"copyResponse": "複製回應",
|
||||
"copied": "已複製!",
|
||||
"failedToCopy": "複製失敗",
|
||||
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
|
||||
"goodResponse": "有幫助",
|
||||
"badResponse": "無幫助",
|
||||
"clickToEdit": "點擊編輯",
|
||||
"editMessage": "編輯訊息",
|
||||
"saveAndSubmit": "儲存並提交",
|
||||
"ExtractURL": "從 URL 擷取"
|
||||
},
|
||||
"examples": {
|
||||
"title": "用 AI 建立圖表",
|
||||
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
|
||||
"quickExamples": "快速範例",
|
||||
"paperToDiagram": "文件轉圖表",
|
||||
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
|
||||
"animatedDiagram": "動畫圖表",
|
||||
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
|
||||
"awsArchitecture": "AWS 架構",
|
||||
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
|
||||
"replicateFlowchart": "複製流程圖",
|
||||
"replicateDescription": "上傳並複製現有流程圖",
|
||||
"creativeDrawing": "創意繪圖",
|
||||
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||
"cachedNote": "範例已快取,可即時回應",
|
||||
"mcpServer": "MCP 伺服器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"preview": "預覽"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"description": "配置您的應用程式設定。",
|
||||
"apiKeysModels": "API 金鑰和模型",
|
||||
"apiKeysModelsDescription": "配置 AI 提供商和 API 金鑰。",
|
||||
"accessCode": "存取碼",
|
||||
"accessCodePlaceholder": "輸入存取碼",
|
||||
"accessCodeDescription": "使用此應用程式需要存取碼。",
|
||||
"aiProvider": "AI 提供商設定",
|
||||
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
|
||||
"provider": "提供商",
|
||||
"modelId": "模型 ID",
|
||||
"apiKey": "API 金鑰",
|
||||
"apiKeyPlaceholder": "您的 API 金鑰",
|
||||
"baseUrl": "基礎 URL(可選)",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"overrides": "覆寫",
|
||||
"clearSettings": "清除設定",
|
||||
"useServerDefault": "使用伺服器預設值",
|
||||
"language": "語言",
|
||||
"languageDescription": "選擇介面語言。",
|
||||
"theme": "主題",
|
||||
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
|
||||
"drawioStyle": "DrawIO 樣式",
|
||||
"drawioStyleDescription": "畫布樣式:",
|
||||
"switchTo": "切換到",
|
||||
"minimal": "簡約",
|
||||
"sketch": "草圖",
|
||||
"diagramStyle": "圖表樣式",
|
||||
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
|
||||
"sendShortcut": "傳送快捷鍵",
|
||||
"sendShortcutDescription": "選擇傳送訊息的方式。",
|
||||
"enterToSend": "Enter 傳送",
|
||||
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
|
||||
"diagramActions": "圖表操作",
|
||||
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
|
||||
"history": "歷史紀錄",
|
||||
"download": "下載",
|
||||
"proxy": "代理設定",
|
||||
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
|
||||
"httpProxy": "HTTP 代理",
|
||||
"httpsProxy": "HTTPS 代理",
|
||||
"applyProxy": "套用",
|
||||
"proxyApplied": "代理設定已套用",
|
||||
"diagramValidation": "圖表驗證(實驗性)",
|
||||
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已停用"
|
||||
},
|
||||
"save": {
|
||||
"title": "儲存圖表",
|
||||
"description": "選擇格式和檔案名稱以儲存您的圖表。",
|
||||
"format": "格式",
|
||||
"filename": "檔案名稱",
|
||||
"filenamePlaceholder": "輸入檔案名稱",
|
||||
"formats": {
|
||||
"drawio": "Draw.io XML",
|
||||
"png": "PNG 圖片",
|
||||
"svg": "SVG 圖片"
|
||||
},
|
||||
"savedSuccessfully": "儲存成功!"
|
||||
},
|
||||
"history": {
|
||||
"title": "圖表歷史",
|
||||
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
|
||||
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
|
||||
"version": "版本",
|
||||
"restoreTo": "還原到版本 {version}?"
|
||||
},
|
||||
"dialogs": {
|
||||
"clearTitle": "清除所有內容?",
|
||||
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
|
||||
"clearEverything": "清除所有內容",
|
||||
"clearSuccess": "已開始新對話"
|
||||
},
|
||||
"errors": {
|
||||
"maxFiles": "檔案太多。最多允許 {max} 個。",
|
||||
"onlyMoreAllowed": "只能再新增 {slots} 個檔案",
|
||||
"fileExceeds": "「{name}」大小為 {size}(超過 {max}MB)",
|
||||
"unsupportedType": "「{name}」不是支援的檔案類型",
|
||||
"filesRejected": "{count} 個檔案被拒絕:",
|
||||
"andMore": "...還有 {count} 個",
|
||||
"invalidAccessCode": "無效或缺少存取碼。請在設定中配置。",
|
||||
"networkError": "網路錯誤。請檢查您的連線。",
|
||||
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
||||
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
||||
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
|
||||
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
|
||||
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
|
||||
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
||||
"failedToSave": "無法儲存訊息到 localStorage",
|
||||
"failedToRestore": "無法從 localStorage 還原",
|
||||
"failedToPersist": "卸載前無法持久化狀態",
|
||||
"failedToExport": "取得圖表資料時出錯",
|
||||
"failedToLoadExample": "載入範例圖片時出錯",
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已達每日配額",
|
||||
"tokenLimit": "已達每日令牌限制",
|
||||
"tpmLimit": "速率限制",
|
||||
"tpmMessage": "請求過多。請稍等片刻。",
|
||||
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
||||
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
||||
"configModel": "使用您的金鑰",
|
||||
"selfHost": "自行託管",
|
||||
"sponsor": "贊助",
|
||||
"learnMore": "了解更多 →",
|
||||
"usedOf": "{used}/{limit}"
|
||||
},
|
||||
"tools": {
|
||||
"generateDiagram": "產生圖表",
|
||||
"editDiagram": "編輯圖表",
|
||||
"appendDiagram": "繼續圖表",
|
||||
"complete": "完成",
|
||||
"error": "錯誤",
|
||||
"truncated": "已截斷"
|
||||
},
|
||||
"file": {
|
||||
"reading": "讀取中...",
|
||||
"chars": "字元",
|
||||
"removeFile": "移除檔案"
|
||||
},
|
||||
"url": {
|
||||
"title": "從 URL 擷取內容",
|
||||
"description": "貼上 URL 以擷取和分析其內容",
|
||||
"Extracting": "擷取中...",
|
||||
"extract": "擷取",
|
||||
"Cancel": "取消",
|
||||
"enterUrl": "請輸入 URL",
|
||||
"invalidFormat": "URL 格式無效"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "思考中...",
|
||||
"thoughtFor": "思考了 {duration} 秒",
|
||||
"thoughtBrief": "思考了幾秒鐘"
|
||||
},
|
||||
"dev": {
|
||||
"title": "開發:XML 串流模擬器",
|
||||
"preset": "預設:",
|
||||
"selectPreset": "選擇預設...",
|
||||
"clear": "清除",
|
||||
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
|
||||
"interval": "間隔:",
|
||||
"chars": "字元:",
|
||||
"streaming": "串流傳輸中...",
|
||||
"simulate": "模擬",
|
||||
"stop": "停止",
|
||||
"testQuotaToast": "測試配額提示",
|
||||
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
|
||||
"successMessage": "成功顯示圖表。"
|
||||
},
|
||||
"about": {
|
||||
"modelChange": "模型變更與用量限制",
|
||||
"walletCrying": "(別名:我的錢包頂不住了)",
|
||||
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
|
||||
"contactMe": "聯絡我",
|
||||
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
|
||||
},
|
||||
"sessionHistory": {
|
||||
"tooltip": "聊天歷史",
|
||||
"newChat": "新對話",
|
||||
"empty": "暫無聊天紀錄",
|
||||
"emptyHint": "開始對話吧",
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"thisWeek": "本週",
|
||||
"earlier": "更早",
|
||||
"deleteTitle": "刪除此對話?",
|
||||
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
|
||||
"recentChats": "最近對話",
|
||||
"justNow": "剛剛",
|
||||
"searchPlaceholder": "搜尋對話...",
|
||||
"noResults": "未找到對話"
|
||||
},
|
||||
"validation": {
|
||||
"title": "驗證圖表",
|
||||
"capturing": "截圖中",
|
||||
"validating": "驗證中",
|
||||
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
|
||||
"valid": "通過",
|
||||
"validWithWarnings": "通過(有警告)",
|
||||
"issuesFound": "發現問題",
|
||||
"error": "錯誤",
|
||||
"skipped": "已跳過",
|
||||
"capturedScreenshot": "截圖預覽:",
|
||||
"issuesFoundLabel": "發現的問題:",
|
||||
"suggestions": "建議:",
|
||||
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
|
||||
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
|
||||
"improveWithSuggestions": "根據建議改進",
|
||||
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
|
||||
},
|
||||
"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": "(可選)",
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"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": "伺服器預設",
|
||||
"serverModels": "伺服器模型",
|
||||
"userModels": "使用者模型",
|
||||
"configureModels": "配置模型...",
|
||||
"onlyVerifiedShown": "僅顯示已驗證的模型",
|
||||
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||
"serverDefaultModel": "伺服器預設模型"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"chat": {
|
||||
"placeholder": "描述您的图表或上传文件...",
|
||||
"send": "发送",
|
||||
"sending": "发送中...",
|
||||
"stopGeneration": "停止生成",
|
||||
"sendMessage": "发送消息",
|
||||
"clearConversation": "清除对话",
|
||||
"diagramHistory": "图表历史",
|
||||
@@ -77,6 +77,8 @@
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"description": "配置您的应用程序设置。",
|
||||
"apiKeysModels": "API 密钥和模型",
|
||||
"apiKeysModelsDescription": "配置 AI 提供商和 API 密钥。",
|
||||
"accessCode": "访问码",
|
||||
"accessCodePlaceholder": "输入访问码",
|
||||
"accessCodeDescription": "使用此应用程序需要访问码。",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||
ALWAYS respond in the same language as the user's last message.
|
||||
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
|
||||
export interface UrlData {
|
||||
url: string
|
||||
@@ -15,7 +16,7 @@ const UrlResponseSchema = z.object({
|
||||
})
|
||||
|
||||
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||
const response = await fetch("/api/parse-url", {
|
||||
const response = await fetch(getApiEndpoint("/api/parse-url"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
|
||||
1212
package-lock.json
generated
1212
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-ai-draw-io",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.12",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
@@ -139,7 +139,7 @@
|
||||
"vite-tsconfig-paths": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"wait-on": "^9.0.3",
|
||||
"wrangler": "4.58.0"
|
||||
"wrangler": "^4.60.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@openrouter/ai-sdk-provider": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.15",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -44,6 +44,17 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
|
||||
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
||||
}
|
||||
|
||||
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
||||
function getMostRecentSessionId(): string | null {
|
||||
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
||||
for (const [sessionId, state] of stateStore) {
|
||||
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
||||
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
||||
}
|
||||
}
|
||||
return mostRecent?.id || null
|
||||
}
|
||||
|
||||
function ensureSessionStateInitialized(sessionId: string): void {
|
||||
if (!sessionId) return
|
||||
if (!isLikelyMcpSessionId(sessionId)) return
|
||||
@@ -195,6 +206,17 @@ function handleRequest(
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
@@ -375,85 +397,202 @@ function getHtmlPage(sessionId: string): string {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Draw.io MCP</title>
|
||||
<title>Next AI Draw.io</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||
#header {
|
||||
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
||||
font-family: system-ui, sans-serif; font-size: 14px;
|
||||
padding: 0 20px; height: 52px;
|
||||
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
||||
border-bottom: 1px solid #e8ecf0;
|
||||
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
position: relative; z-index: 10;
|
||||
}
|
||||
#header .brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
#header .logo {
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
background: #18181b;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
||||
#header .title {
|
||||
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
#header .session {
|
||||
font-size: 11px; color: #8b95a5; font-weight: 400;
|
||||
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
||||
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
#header .right { display: flex; align-items: center; gap: 12px; }
|
||||
#save-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||
color: white; border: none; cursor: pointer;
|
||||
font-weight: 500; font-family: inherit;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#save-btn svg { width: 14px; height: 14px; }
|
||||
#save-btn:hover {
|
||||
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
#save-btn:active { transform: translateY(0); }
|
||||
#save-btn:disabled, #history-btn:disabled {
|
||||
background: #e5e7eb; color: #9ca3af;
|
||||
cursor: not-allowed; transform: none; box-shadow: none;
|
||||
}
|
||||
#header .session { color: #888; font-size: 12px; }
|
||||
#header .status { font-size: 12px; }
|
||||
#header .status.connected { color: #4ade80; }
|
||||
#header .status.disconnected { color: #f87171; }
|
||||
#drawio { flex: 1; border: none; }
|
||||
#history-btn {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: #3b82f6; color: white; border: none; cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
||||
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||
cursor: pointer; font-weight: 500; font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
#history-btn:hover { background: #2563eb; }
|
||||
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
||||
#history-btn svg { width: 24px; height: 24px; }
|
||||
#history-modal {
|
||||
#history-btn svg { width: 14px; height: 14px; }
|
||||
#history-btn:hover {
|
||||
background: #e4e4e7; border-color: #d4d4d8;
|
||||
}
|
||||
#drawio { flex: 1; border: none; }
|
||||
#history-modal, #save-modal {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5); z-index: 2000;
|
||||
align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
||||
z-index: 2000; align-items: center; justify-content: center;
|
||||
}
|
||||
#history-modal.open { display: flex; }
|
||||
#history-modal.open, #save-modal.open { display: flex; }
|
||||
.modal-content {
|
||||
background: white; border-radius: 12px;
|
||||
width: 90%; max-width: 500px; max-height: 70vh;
|
||||
background: white; border-radius: 16px;
|
||||
width: 90%; max-width: 480px; max-height: 70vh;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
||||
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
animation: modalIn 0.2s ease-out;
|
||||
}
|
||||
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
||||
.modal-header h2 { font-size: 18px; margin: 0; }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
||||
}
|
||||
.modal-header h2 {
|
||||
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
.modal-footer {
|
||||
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
||||
display: flex; gap: 10px; justify-content: flex-end;
|
||||
}
|
||||
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||
.history-item {
|
||||
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
||||
cursor: pointer; text-align: center;
|
||||
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
||||
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
||||
background: #fafafa;
|
||||
}
|
||||
.history-item:hover { border-color: #a1a1aa; background: white; }
|
||||
.history-item.selected {
|
||||
border-color: #18181b; background: white;
|
||||
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
||||
}
|
||||
.history-item:hover { border-color: #3b82f6; }
|
||||
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
||||
.history-item .thumb {
|
||||
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
||||
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 4px; overflow: hidden;
|
||||
margin-bottom: 6px; overflow: hidden;
|
||||
}
|
||||
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.history-item .label { font-size: 12px; color: #666; }
|
||||
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
.empty { text-align: center; padding: 40px; color: #666; }
|
||||
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
||||
.btn {
|
||||
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
||||
cursor: pointer; border: none; font-weight: 500;
|
||||
font-family: inherit; transition: all 0.15s ease;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(to bottom, #18181b, #27272a);
|
||||
color: white;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
background: #e4e4e7; color: #a1a1aa;
|
||||
cursor: not-allowed; transform: none; box-shadow: none;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
||||
}
|
||||
.btn-secondary:hover { background: #e4e4e7; }
|
||||
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 500;
|
||||
margin-bottom: 8px; color: #3f3f46;
|
||||
}
|
||||
.form-group select, .form-group input {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
||||
border-radius: 8px; font-size: 14px; outline: none;
|
||||
font-family: inherit; background: white;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.form-group select:focus, .form-group input:focus {
|
||||
border-color: #18181b;
|
||||
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
||||
}
|
||||
.filename-group { display: flex; }
|
||||
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
||||
.filename-group .ext {
|
||||
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
||||
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<div>
|
||||
<strong>Draw.io MCP</strong>
|
||||
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
||||
<div class="brand">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
||||
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
||||
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="title">Next AI Draw.io</span>
|
||||
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
History
|
||||
</button>
|
||||
<button id="save-btn" ${sessionId ? "" : "disabled"}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<div id="status" class="status disconnected">Connecting...</div>
|
||||
</div>
|
||||
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
||||
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
||||
</div>
|
||||
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="history-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h2>History</h2></div>
|
||||
@@ -467,10 +606,35 @@ function getHtmlPage(sessionId: string): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="save-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h2>Download Diagram</h2></div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Format</label>
|
||||
<select id="save-format">
|
||||
<option value="drawio">Draw.io (.drawio)</option>
|
||||
<option value="png">PNG Image (.png)</option>
|
||||
<option value="svg">SVG Vector (.svg)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Filename</label>
|
||||
<div class="filename-group">
|
||||
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
|
||||
<span class="ext" id="save-ext">.drawio</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const sessionId = "${sessionId}";
|
||||
const iframe = document.getElementById('drawio');
|
||||
const statusEl = document.getElementById('status');
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
let pendingSvgExport = null;
|
||||
let pendingAiSvg = false;
|
||||
@@ -481,8 +645,6 @@ function getHtmlPage(sessionId: string): string {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.event === 'init') {
|
||||
isReady = true;
|
||||
statusEl.textContent = 'Ready';
|
||||
statusEl.className = 'status connected';
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
// Request SVG export, then push state with SVG
|
||||
@@ -491,6 +653,23 @@ function getHtmlPage(sessionId: string): string {
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||
const dl = pendingDownload;
|
||||
pendingDownload = null;
|
||||
let dataUrl = msg.data;
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
||||
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl; a.download = dl.filename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
saveModal.classList.remove('open');
|
||||
saveConfirmBtn.disabled = false;
|
||||
saveConfirmBtn.textContent = 'Save';
|
||||
return;
|
||||
}
|
||||
// Handle sync export (XML format) - server requested fresh state
|
||||
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
||||
pendingSyncExport = false;
|
||||
@@ -563,6 +742,64 @@ function getHtmlPage(sessionId: string): string {
|
||||
|
||||
if (sessionId) { poll(); setInterval(poll, 2000); }
|
||||
|
||||
// Save modal
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const saveModal = document.getElementById('save-modal');
|
||||
const saveFormat = document.getElementById('save-format');
|
||||
const saveFilename = document.getElementById('save-filename');
|
||||
const saveExt = document.getElementById('save-ext');
|
||||
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
||||
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
||||
let pendingDownload = null;
|
||||
|
||||
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
|
||||
|
||||
saveBtn.onclick = () => {
|
||||
if (!sessionId || !isReady) return;
|
||||
saveModal.classList.add('open');
|
||||
saveFilename.focus();
|
||||
saveFilename.select();
|
||||
};
|
||||
|
||||
saveFormat.onchange = () => {
|
||||
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
||||
};
|
||||
|
||||
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
||||
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
||||
|
||||
saveConfirmBtn.onclick = () => {
|
||||
const format = saveFormat.value;
|
||||
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
||||
saveConfirmBtn.disabled = true;
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
if (format === 'drawio') {
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||
let xmlData = lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
}
|
||||
const blob = new Blob([xmlData], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
saveModal.classList.remove('open');
|
||||
saveConfirmBtn.disabled = false;
|
||||
saveConfirmBtn.textContent = 'Save';
|
||||
} else if (format === 'png') {
|
||||
pendingDownload = { format: 'png', filename };
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
|
||||
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||
} else if (format === 'svg') {
|
||||
pendingDownload = { format: 'svg', filename };
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// History UI
|
||||
const historyBtn = document.getElementById('history-btn');
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
|
||||
1
proxy.ts
1
proxy.ts
@@ -31,6 +31,7 @@ export function proxy(request: NextRequest) {
|
||||
if (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/drawio") ||
|
||||
pathname.includes("/favicon") ||
|
||||
/\.(.*)$/.test(pathname)
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* electron-builder afterPack hook
|
||||
* Copies node_modules to the standalone directory in the packaged app
|
||||
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
|
||||
*/
|
||||
|
||||
const {
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
statSync,
|
||||
} = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
/**
|
||||
* Copy directory recursively, converting symlinks to regular files/directories.
|
||||
@@ -80,4 +82,22 @@ module.exports = async (context) => {
|
||||
"Ensure 'npm run electron:prepare' was run before building.",
|
||||
)
|
||||
}
|
||||
|
||||
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
|
||||
if (context.packager.platform.name === "mac") {
|
||||
const appPath = path.join(
|
||||
appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
)
|
||||
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
|
||||
try {
|
||||
execSync(`codesign --force --deep --sign - "${appPath}"`, {
|
||||
stdio: "inherit",
|
||||
})
|
||||
console.log("[afterPack] Ad-hoc signing completed successfully")
|
||||
} catch (error) {
|
||||
console.error("[afterPack] Ad-hoc signing failed:", error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,11 @@ describe("supportsImageInput", () => {
|
||||
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for Kimi K2.5 models (supports vision)", () => {
|
||||
expect(supportsImageInput("kimi-k2.5")).toBe(true)
|
||||
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for DeepSeek text models", () => {
|
||||
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
||||
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user