mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 17:57:47 +08:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d5c83b0d | ||
|
|
18a20aa1ca | ||
|
|
802f0ada9f | ||
|
|
257a4bca06 | ||
|
|
83f190e1cd | ||
|
|
8639779a23 | ||
|
|
38b72e89b6 | ||
|
|
6e77b08eb3 | ||
|
|
3855ff2b02 | ||
|
|
668f79c0ab | ||
|
|
75be7cb3ea | ||
|
|
29736bb528 | ||
|
|
73aa6a45f6 | ||
|
|
3c9bcfb978 | ||
|
|
ba2a789d20 | ||
|
|
1a63498d5d | ||
|
|
dee0c80b3e | ||
|
|
4612f7c38d | ||
|
|
f3a4c98f26 | ||
|
|
6857633105 | ||
|
|
57729cf174 | ||
|
|
9c46e760af | ||
|
|
21ab839be7 | ||
|
|
23b76c098b | ||
|
|
a87ada3776 | ||
|
|
4c8d8427bc | ||
|
|
d47c5ebb4e | ||
|
|
52b1f24447 | ||
|
|
7fccf68edf | ||
|
|
0b63e28e5a | ||
|
|
a45e5b6796 | ||
|
|
113608ec02 | ||
|
|
58b73ef761 | ||
|
|
ea16ea1ece | ||
|
|
027cd88c90 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
e355a50890 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
42ab388118 | ||
|
|
dfedd1b881 | ||
|
|
29acbc33b8 | ||
|
|
d6bdb25f58 | ||
|
|
eb2fa69f58 | ||
|
|
c1e4b1c9bf | ||
|
|
d08b28821e | ||
|
|
8986217a73 | ||
|
|
73788abff9 | ||
|
|
de0ba6b718 | ||
|
|
a8ff1ea2bc | ||
|
|
b723406a48 | ||
|
|
783cb4a6f1 | ||
|
|
801974a18b | ||
|
|
5f71b2783f | ||
|
|
691c7e34bf | ||
|
|
0d50a179aa |
@@ -33,6 +33,11 @@
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Run Biome format
|
||||
# Pin to the version in package.json so CI matches local/pre-commit
|
||||
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
|
||||
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
|
||||
run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched .
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
|
||||
@@ -34,18 +34,13 @@ jobs:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
run: |
|
||||
rm -rf public/drawio
|
||||
git clone --depth 1 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
|
||||
|
||||
# The download script reads .env files with @next/env
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
run: node scripts/fetch-drawio.mjs
|
||||
|
||||
- name: Build and publish
|
||||
run: npm run dist:${{ matrix.platform }}
|
||||
env:
|
||||
@@ -66,19 +61,13 @@ 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 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
|
||||
|
||||
# The download script reads .env files with @next/env
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Download draw.io static files for offline use
|
||||
run: node scripts/fetch-drawio.mjs
|
||||
|
||||
# Build WITHOUT publishing
|
||||
- name: Build Windows app
|
||||
run: npm run dist:win:build
|
||||
|
||||
@@ -62,6 +62,10 @@ jobs:
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Build and check package contents
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm run build && npm run check-package
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
# Tests run from src/, so check the built npm package separately
|
||||
- name: Build MCP server and check package contents
|
||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+3
-2
@@ -23,8 +23,9 @@ COPY . .
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time argument for self-hosted draw.io URL
|
||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net
|
||||
# Build-time argument for an external draw.io URL. Leave empty to use the
|
||||
# copy bundled into public/drawio (downloaded by `npm run build`)
|
||||
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
||||
|
||||
# Build-time argument to show About link and Notice icon
|
||||
|
||||
@@ -21,6 +21,17 @@ A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
||||
|
||||
> 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/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
|
||||
@@ -96,7 +107,8 @@ Here are some example prompts and their generated diagrams:
|
||||
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
|
||||
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
|
||||
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **Versions and Undo**: Every AI change shows up in the chat as a version with a thumbnail. Compare it with the canvas, restore it, or undo it; Ctrl+Z on the canvas also takes back an AI change in one step. Shapes the AI just changed are highlighted.
|
||||
- **Ask About a Selection**: Select shapes on the canvas and ask the AI to change just those.
|
||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
@@ -127,6 +139,13 @@ Then ask Claude to create diagrams:
|
||||
|
||||
The diagram appears in your browser in real-time!
|
||||
|
||||
The MCP server includes most of the web app's drawing features:
|
||||
|
||||
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||
- A screenshot tool, so the AI can check the rendered diagram and fix it
|
||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
|
||||
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
|
||||
|
||||
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
|
||||
|
||||
## Getting Started
|
||||
@@ -217,6 +236,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
@@ -244,7 +264,7 @@ The application uses the following technologies:
|
||||
|
||||
- **Next.js**: For the frontend framework and routing
|
||||
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
|
||||
- **react-drawio**: For diagram representation and manipulation
|
||||
- **draw.io**: The editor runs from a copy bundled into `public/drawio` (downloaded by `npm run dev` / `npm run build`), so the app can drive it directly
|
||||
|
||||
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
|
||||
|
||||
@@ -253,6 +273,8 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 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 [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||
|
||||
@@ -149,8 +149,10 @@ export default function AboutCN() {
|
||||
:上传现有图表或图像,让AI自动复制和增强
|
||||
</li>
|
||||
<li>
|
||||
<strong>图表历史记录</strong>
|
||||
:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
<strong>版本与撤销</strong>
|
||||
:AI
|
||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
||||
Ctrl+Z 也能撤回
|
||||
</li>
|
||||
<li>
|
||||
<strong>交互式聊天界面</strong>
|
||||
@@ -294,7 +296,8 @@ export default function AboutCN() {
|
||||
):用于流式AI响应和多提供商支持
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>:用于图表表示和操作
|
||||
<strong>draw.io</strong>
|
||||
:内置在应用里的编辑器,应用可以直接控制它
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
@@ -138,8 +138,10 @@ export default function AboutJA() {
|
||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
</li>
|
||||
<li>
|
||||
<strong>ダイアグラム履歴</strong>
|
||||
:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
<strong>バージョンと取り消し</strong>
|
||||
:AI
|
||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
||||
Ctrl+Z を押しても元に戻せます
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
@@ -290,8 +292,8 @@ export default function AboutJA() {
|
||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>
|
||||
:ダイアグラムの表現と操作
|
||||
<strong>draw.io</strong>
|
||||
:アプリに同梱されたエディター。アプリから直接操作できます
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
@@ -150,10 +150,10 @@ export default function About() {
|
||||
replicate and enhance them automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Diagram History</strong>: Comprehensive
|
||||
version control that tracks all changes, allowing
|
||||
you to view and restore previous versions of your
|
||||
diagrams before the AI editing
|
||||
<strong>Versions and Undo</strong>: Every AI change
|
||||
becomes a version in the chat that you can compare,
|
||||
restore or undo; Ctrl+Z on the canvas takes it back
|
||||
too
|
||||
</li>
|
||||
<li>
|
||||
<strong>Interactive Chat Interface</strong>:
|
||||
@@ -304,8 +304,8 @@ export default function About() {
|
||||
and multi-provider support
|
||||
</li>
|
||||
<li>
|
||||
<strong>react-drawio</strong>: For diagram
|
||||
representation and manipulation
|
||||
<strong>draw.io</strong>: The editor, bundled with
|
||||
the app so it can be driven directly
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-gray-700 mt-4">
|
||||
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
@@ -87,6 +89,11 @@ function ProviderDetail({
|
||||
try {
|
||||
const data = await adminFetch("/api/admin/test-model", password, {
|
||||
method: "POST",
|
||||
// EdgeOne's function also checks the access code
|
||||
headers: {
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
@@ -225,6 +232,7 @@ function ProviderDetail({
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
@@ -390,12 +398,14 @@ function ProviderDetail({
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
envHasDefaultModel,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
envHasDefaultModel: boolean
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
@@ -409,10 +419,16 @@ export function ModelsSection({
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||
id: generateId(),
|
||||
provider,
|
||||
models: [],
|
||||
isDefault: providers.length === 0,
|
||||
// Only the very first provider becomes the default, and only when
|
||||
// the env config has no default that it would replace on save
|
||||
isDefault:
|
||||
providers.length === 0 &&
|
||||
!envProviders.some((p) => p.isDefault) &&
|
||||
!envHasDefaultModel,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
@@ -496,7 +512,9 @@ export function ModelsSection({
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
{/* Always empty so picking the same type again still fires */}
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
|
||||
+49
-14
@@ -37,6 +37,19 @@ import { SettingField } from "./setting-field"
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
// For each toggleable group, whether any of its settings has a value (from
|
||||
// the settings file or the environment)
|
||||
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable) continue
|
||||
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
@@ -62,6 +75,8 @@ export default function AdminPage() {
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
@@ -88,15 +103,13 @@ export default function AdminPage() {
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// Seed each toggle once from whether the group has configured
|
||||
// values; don't stomp a user's explicit toggle on later saves
|
||||
// A group stays on while it still has values (e.g. from env vars
|
||||
// that saving can't remove); a user's explicit "on" for a group
|
||||
// with no values yet is kept across saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable || group.id in next) continue
|
||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
const next = groupsWithValues(map)
|
||||
for (const id of Object.keys(next)) {
|
||||
next[id] = next[id] || !!prev[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -108,10 +121,12 @@ export default function AdminPage() {
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
envHasDefaultModel?: boolean
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||
},
|
||||
[],
|
||||
)
|
||||
@@ -181,8 +196,9 @@ export default function AdminPage() {
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
// value undefined drops the pending change (back to the saved value)
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
(key: string, value: string | null | undefined) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
@@ -201,7 +217,7 @@ export default function AdminPage() {
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (isRevert || isNoop) {
|
||||
if (value === undefined || isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
@@ -225,9 +241,10 @@ export default function AdminPage() {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion only for values currently set
|
||||
if (settings[key]?.source !== "default")
|
||||
next[key] = null
|
||||
// Stage deletion of saved values; drop unsaved input
|
||||
if (settings[key]?.source === "default")
|
||||
delete next[key]
|
||||
else next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
@@ -447,6 +464,7 @@ export default function AdminPage() {
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
envHasDefaultModel={envHasDefaultModel}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
@@ -462,6 +480,11 @@ export default function AdminPage() {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
// Values from env vars can't be removed here, so the
|
||||
// group can't be turned off from the panel
|
||||
const envLocked = defs.some(
|
||||
(d) => settings[d.key]?.source === "env",
|
||||
)
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
@@ -480,6 +503,11 @@ export default function AdminPage() {
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
title={
|
||||
envLocked
|
||||
? dict.admin.sourceEnvTitle
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
@@ -494,7 +522,11 @@ export default function AdminPage() {
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={!writable || saving}
|
||||
disabled={
|
||||
!writable ||
|
||||
saving ||
|
||||
envLocked
|
||||
}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
@@ -579,6 +611,9 @@ export default function AdminPage() {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
setEnabledGroups(
|
||||
groupsWithValues(settings),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{dict.admin.discard}
|
||||
|
||||
@@ -73,8 +73,10 @@ export function SecretInput({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker as it was at mount, to revert to on empty
|
||||
const [original] = useState(value)
|
||||
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||
// passes server state (a marker or nothing), e.g. after a save.
|
||||
const [original, setOriginal] = useState(value)
|
||||
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
@@ -146,7 +148,8 @@ export function SettingField({
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
onChange: (value: string | null) => void
|
||||
// undefined drops the pending change (back to the saved value)
|
||||
onChange: (value: string | null | undefined) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
@@ -226,16 +229,18 @@ export function SettingField({
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
keepOnEmpty={source === "file"}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? currentValue)
|
||||
: (secretState ?? undefined)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : "")
|
||||
onChange(typeof v === "string" ? v : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+15
-4
@@ -1,19 +1,25 @@
|
||||
import { GoogleAnalytics } from "@next/third-parties/google"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
|
||||
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
||||
import { notFound } from "next/navigation"
|
||||
import Script from "next/script"
|
||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
|
||||
import { THEME_INIT_SCRIPT } from "@/stores/settings-store"
|
||||
|
||||
import "../globals.css"
|
||||
|
||||
const plusJakarta = Plus_Jakarta_Sans({
|
||||
variable: "--font-sans",
|
||||
const instrumentSans = Instrument_Sans({
|
||||
variable: "--font-latin",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
// Chinese and Japanese text falls through to system fonts, in the
|
||||
// order globals.css picks for the page's language
|
||||
fallback: [],
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
@@ -165,17 +171,22 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang={validLang} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Apply dark mode before the first paint (no white flash) */}
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DictionaryProvider dictionary={dictionary}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</DictionaryProvider>
|
||||
<AppToaster />
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
|
||||
|
||||
+6
-249
@@ -1,254 +1,11 @@
|
||||
"use client"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { DrawIoEmbed } from "react-drawio"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import ChatPanel from "@/components/chat-panel"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { Suspense } from "react"
|
||||
import { Workspace } from "@/components/workspace/workspace"
|
||||
|
||||
export default function Home() {
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
} = useDiagram()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isChatVisible, setIsChatVisible] = useState(true)
|
||||
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
||||
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)
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
// Restore saved locale and redirect if needed
|
||||
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
||||
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
||||
const pathParts = pathname.split("/").filter(Boolean)
|
||||
const currentLocale = pathParts[0]
|
||||
if (currentLocale !== savedLocale) {
|
||||
pathParts[0] = savedLocale
|
||||
router.replace(`/${pathParts.join("/")}`)
|
||||
return // Wait for redirect
|
||||
}
|
||||
}
|
||||
|
||||
const savedUi = localStorage.getItem("drawio-theme")
|
||||
if (isDrawioTheme(savedUi)) {
|
||||
setDrawioUi(savedUi)
|
||||
}
|
||||
|
||||
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
|
||||
if (savedDarkMode !== null) {
|
||||
const isDark = savedDarkMode === "true"
|
||||
setDarkMode(isDark)
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
} else {
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches
|
||||
setDarkMode(prefersDark)
|
||||
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])
|
||||
|
||||
const handleDrawioLoad = useCallback(() => {
|
||||
setIsDrawioReady(true)
|
||||
onDrawioLoad()
|
||||
}, [onDrawioLoad])
|
||||
|
||||
const handleDarkModeChange = () => {
|
||||
const newValue = !darkMode
|
||||
setDarkMode(newValue)
|
||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||
document.documentElement.classList.toggle("dark", newValue)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
||||
localStorage.setItem("drawio-theme", theme)
|
||||
setDrawioUi(theme)
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile - reset draw.io before crossing breakpoint
|
||||
const isInitialRenderRef = useRef(true)
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const newIsMobile = window.innerWidth < 768
|
||||
if (
|
||||
!isInitialRenderRef.current &&
|
||||
newIsMobile !== isMobileRef.current
|
||||
) {
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
isMobileRef.current = newIsMobile
|
||||
isInitialRenderRef.current = false
|
||||
setIsMobile(newIsMobile)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [resetDrawioReady])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
if (panel) {
|
||||
if (panel.isCollapsed()) {
|
||||
panel.expand()
|
||||
setIsChatVisible(true)
|
||||
} else {
|
||||
panel.collapse()
|
||||
setIsChatVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut for toggling chat panel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
||||
event.preventDefault()
|
||||
toggleChatPanel()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// The workspace reads ?session= from the URL, which needs a Suspense boundary
|
||||
return (
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
id="main-panel-group"
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="drawio-panel"
|
||||
defaultSize={isMobile ? 50 : 67}
|
||||
minSize={20}
|
||||
>
|
||||
<div
|
||||
className={`h-full relative ${
|
||||
isMobile ? "p-1" : "p-2"
|
||||
}`}
|
||||
>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||
{isLoaded && (
|
||||
<div
|
||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||
>
|
||||
<DrawIoEmbed
|
||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||
ref={drawioRef}
|
||||
autosave
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onLoad={handleDrawioLoad}
|
||||
baseUrl={drawioBaseUrl}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: false,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noSaveBtn: true,
|
||||
noExitBtn: true,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
lang: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
offline: true,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!isLoaded || !isDrawioReady) && (
|
||||
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||
<span className="text-muted-foreground">
|
||||
Draw.io panel is loading...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
id="chat-panel"
|
||||
ref={chatPanelRef}
|
||||
defaultSize={isMobile ? 50 : 33}
|
||||
minSize={isMobile ? 20 : 15}
|
||||
maxSize={isMobile ? 80 : 50}
|
||||
collapsible={!isMobile}
|
||||
collapsedSize={isMobile ? 0 : 3}
|
||||
onCollapse={() => setIsChatVisible(false)}
|
||||
onExpand={() => setIsChatVisible(true)}
|
||||
>
|
||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
||||
Loading chat...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={toggleChatPanel}
|
||||
drawioUi={drawioUi}
|
||||
onDrawioUiChange={handleDrawioUiChange}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={handleDarkModeChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
|
||||
<Workspace />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
||||
import {
|
||||
getEnvFallback,
|
||||
isSettingsWritable,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
@@ -33,6 +37,9 @@ async function payload() {
|
||||
models: p.models,
|
||||
isDefault: !!p.default && !adminHasDefault,
|
||||
})) ?? [],
|
||||
// Whether .env sets a default model. getEnvFallback skips the value
|
||||
// the panel overlays onto process.env, so a panel default doesn't count.
|
||||
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
loadAdminProviders,
|
||||
mergeSecrets,
|
||||
} from "@/lib/admin/providers"
|
||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -47,14 +48,31 @@ export async function POST(req: Request) {
|
||||
sameEndpoint && stored ? [stored] : [],
|
||||
)
|
||||
|
||||
const serverUrl = globalBaseUrl(resolved.provider)
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Checked again there, in place of an access code
|
||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
||||
// The EdgeOne function checks the access code and Pages
|
||||
// cookies, and its URL is built from the page's origin
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
...(req.headers.get("origin") && {
|
||||
origin: req.headers.get("origin") as string,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
apiKey: resolved.apiKey,
|
||||
baseUrl: resolved.baseUrl,
|
||||
// Without a URL of its own, chat sends the entry's key to
|
||||
// the server's <P>_BASE_URL: test that endpoint, not
|
||||
// another one. It is the server's own, which chat uses
|
||||
// without the checks for a URL a user typed.
|
||||
baseUrl: resolved.baseUrl || serverUrl,
|
||||
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
AIHUBMIX_MODELS_ENDPOINT,
|
||||
extractAihubmixModelIds,
|
||||
} from "@/lib/aihubmix-models"
|
||||
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
|
||||
const SUCCESS_CACHE_CONTROL =
|
||||
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
|
||||
|
||||
function fallbackResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
models: SUGGESTED_MODELS.aihubmix || [],
|
||||
source: "fallback",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[aihubmix-models] Failed to fetch models: ${response.status}`,
|
||||
)
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
const payload = await response.json()
|
||||
const models = extractAihubmixModelIds(payload)
|
||||
|
||||
if (models.length === 0) {
|
||||
console.warn("[aihubmix-models] Model list response was empty")
|
||||
return fallbackResponse()
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
models,
|
||||
source: "aihubmix",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": SUCCESS_CACHE_CONTROL,
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn("[aihubmix-models] Failed to load models:", error)
|
||||
return fallbackResponse()
|
||||
}
|
||||
}
|
||||
+309
-334
@@ -4,41 +4,67 @@ import {
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
InvalidToolInputError,
|
||||
LoadAPIKeyError,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import {
|
||||
CACHE_POINT,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
getServerProvider,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
isMinimalDiagram,
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
formatSelectionContext,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import {
|
||||
endTrace,
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
import { findServerModelById } from "@/lib/server-model-config"
|
||||
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
|
||||
import {
|
||||
resolveMaxOutputTokens,
|
||||
withOutputTokenLimitFallback,
|
||||
} from "@/lib/output-token-limit"
|
||||
import {
|
||||
type FlattenedServerModel,
|
||||
findServerModelById,
|
||||
} from "@/lib/server-model-config"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { getSystemPrompt } from "@/lib/system-prompts"
|
||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import {
|
||||
getShapeLibrary,
|
||||
SHAPE_LIBRARY_LIST,
|
||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
||||
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
|
||||
|
||||
export const maxDuration = 120
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
@@ -70,24 +96,18 @@ function createCachedStreamResponse(xml: string): Response {
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
}
|
||||
|
||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||
const modelStreamResponses = new WeakSet<Response>()
|
||||
|
||||
// Inner handler function
|
||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Check for access code
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
}
|
||||
const accessDenied = checkAccessCode(req)
|
||||
if (accessDenied) return accessDenied
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, xml, previousXml, sessionId } = body
|
||||
@@ -120,36 +140,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
userId: userId,
|
||||
})
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
|
||||
const hasOwnApiKey = !!(
|
||||
req.headers.get("x-ai-provider") &&
|
||||
(req.headers.get("x-ai-api-key") ||
|
||||
req.headers.get("x-aws-access-key-id") ||
|
||||
req.headers.get("x-vertex-api-key"))
|
||||
)
|
||||
|
||||
// Skip quota check if: quota disabled, user has own API key, or is anonymous
|
||||
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// === FILE VALIDATION START ===
|
||||
const fileValidation = validateFileParts(messages)
|
||||
if (!fileValidation.valid) {
|
||||
@@ -159,7 +149,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// === CACHE CHECK START ===
|
||||
const isFirstMessage = messages.length === 1
|
||||
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
|
||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
||||
|
||||
if (isFirstMessage && isEmptyDiagram) {
|
||||
const lastMessage = messages[0]
|
||||
@@ -179,24 +169,15 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
let baseUrl = req.headers.get("x-ai-base-url")
|
||||
const selectedModelId = req.headers.get("x-selected-model-id")
|
||||
|
||||
// For EdgeOne provider, construct full URL from request origin
|
||||
// because createOpenAI needs absolute URL, not relative path
|
||||
if (provider === "edgeone" && !baseUrl) {
|
||||
const origin = req.headers.get("origin") || new URL(req.url).origin
|
||||
baseUrl = `${origin}/api/edgeai`
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
// Check if this is a server model with custom env var names
|
||||
let serverModelConfig: {
|
||||
apiKeyEnv?: string | string[]
|
||||
baseUrlEnv?: string
|
||||
provider?: string
|
||||
} = {}
|
||||
let serverModel: FlattenedServerModel | null = null
|
||||
if (selectedModelId?.startsWith("server:")) {
|
||||
const serverModel = await findServerModelById(selectedModelId)
|
||||
serverModel = await findServerModelById(selectedModelId)
|
||||
console.log(
|
||||
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
||||
)
|
||||
@@ -210,12 +191,41 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// A server model's provider comes from its config: for one set up in
|
||||
// the admin panel the header holds the provider name's slug. Without
|
||||
// either, the server's own AI_PROVIDER.
|
||||
const isEdgeOne =
|
||||
(serverModelConfig.provider || provider || getServerProvider()) ===
|
||||
"edgeone"
|
||||
|
||||
// EdgeOne is this deployment's own function, whatever URL the request
|
||||
// names: another host would get the user's EdgeOne cookies, and the
|
||||
// quota counts it. Absolute, as the SDK needs.
|
||||
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return Response.json(
|
||||
{ error: "Private or internal base URLs are not allowed." },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
const clientOverrides = {
|
||||
// Server model provider takes precedence over client header
|
||||
provider: serverModelConfig.provider || provider,
|
||||
// Server model provider takes precedence over client header; EdgeOne
|
||||
// named only in AI_PROVIDER is named here, for its own base URL
|
||||
provider:
|
||||
serverModelConfig.provider ||
|
||||
provider ||
|
||||
(isEdgeOne ? "edgeone" : null),
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
modelId: req.headers.get("x-ai-model"),
|
||||
// A server model runs the model it was configured with, whatever the header says
|
||||
modelId: serverModel?.modelId || req.headers.get("x-ai-model"),
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
|
||||
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
|
||||
@@ -225,11 +235,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
...serverModelConfig,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication
|
||||
...(provider === "edgeone" &&
|
||||
cookieHeader && {
|
||||
headers: { cookie: cookieHeader },
|
||||
}),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(isEdgeOne && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Read minimal style preference from header
|
||||
@@ -241,13 +254,85 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// Get AI model with optional client overrides
|
||||
const {
|
||||
model,
|
||||
model: baseModel,
|
||||
providerOptions,
|
||||
headers,
|
||||
modelId,
|
||||
provider: resolvedProvider,
|
||||
} = getAIModel(clientOverrides)
|
||||
|
||||
// On the server's own keys, only run models the server offers: a server
|
||||
// model picked by id (its model name is fixed above) or one in AI_MODEL
|
||||
// on AI_PROVIDER. With their own key, users can run any model.
|
||||
const onServerCredentials = usesServerCredentials(
|
||||
resolvedProvider,
|
||||
clientOverrides,
|
||||
)
|
||||
const envModels =
|
||||
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
|
||||
const offeredInEnv =
|
||||
envModels.includes(modelId) && resolvedProvider === getServerProvider()
|
||||
if (onServerCredentials && !serverModel && !offeredInEnv) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// === SERVER-SIDE QUOTA CHECK START ===
|
||||
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
|
||||
// server's keys, or on the server's own endpoints: EdgeOne, its keyless
|
||||
// Ollama, and anything at a private address (the server's network,
|
||||
// which ignores a dummy key header). Bedrock and EdgeOne never use the
|
||||
// base URL header. In the desktop app every endpoint is the user's.
|
||||
const clientBaseUrl = normalizeBaseUrl(
|
||||
req.headers.get("x-ai-base-url") ?? "",
|
||||
)
|
||||
const onServerEndpoint = await usesServerEndpoint(
|
||||
resolvedProvider,
|
||||
clientBaseUrl,
|
||||
clientOverrides.apiKey,
|
||||
)
|
||||
const countsQuota =
|
||||
isQuotaEnabled() &&
|
||||
(onServerCredentials || onServerEndpoint) &&
|
||||
userId !== "anonymous"
|
||||
if (countsQuota) {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
// === SERVER-SIDE QUOTA CHECK END ===
|
||||
|
||||
// Retry once if the provider rejects the requested budget, or (newer
|
||||
// Claude models) the sampling or thinking settings
|
||||
const model = withOutputTokenLimitFallback(
|
||||
withDeprecatedParamsFallback(baseModel),
|
||||
)
|
||||
|
||||
// The user setting can raise the budget only on their own key (in the
|
||||
// desktop app every key is the user's); on the server's keys or own
|
||||
// endpoints it can only lower it
|
||||
const maxOutputTokens = resolveMaxOutputTokens(
|
||||
req.headers.get("x-max-output-tokens"),
|
||||
onServerCredentials || onServerEndpoint,
|
||||
)
|
||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||
|
||||
// Check if model supports prompt caching
|
||||
const shouldCache = supportsPromptCaching(modelId)
|
||||
console.log(
|
||||
@@ -276,39 +361,46 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
${userInputText}
|
||||
"""`
|
||||
|
||||
// Convert UIMessages to ModelMessages and add system message
|
||||
const modelMessages = await convertToModelMessages(messages)
|
||||
|
||||
// DEBUG: Log incoming messages structure
|
||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||
messages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] Message ${idx} role:`,
|
||||
msg.role,
|
||||
"parts count:",
|
||||
msg.parts?.length,
|
||||
)
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-invocation" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input && typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
||||
// call that never got its result (the user stopped while it ran) is
|
||||
// left out: the SDK would refuse this and every later request of the
|
||||
// chat (MissingToolResultsError)
|
||||
const modelMessages = await convertToModelMessages(messages, {
|
||||
ignoreIncompleteToolCalls: true,
|
||||
})
|
||||
|
||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
||||
messages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] Message ${idx} role:`,
|
||||
msg.role,
|
||||
"parts count:",
|
||||
msg.parts?.length,
|
||||
)
|
||||
if (msg.parts) {
|
||||
msg.parts.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-invocation" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Part ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputKeys:
|
||||
part.input && typeof part.input === "object"
|
||||
? Object.keys(part.input)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Replace historical tool call XML with placeholders to reduce tokens
|
||||
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
|
||||
const enableHistoryReplace =
|
||||
@@ -325,61 +417,43 @@ ${userInputText}
|
||||
)
|
||||
|
||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
|
||||
enhancedMessages = enhancedMessages
|
||||
.map((msg: any) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const filteredContent = msg.content.filter((part: any) => {
|
||||
if (part.type === "tool-call") {
|
||||
// Check if input is a valid object (not null, undefined, or empty)
|
||||
if (
|
||||
!part.input ||
|
||||
typeof part.input !== "object" ||
|
||||
Object.keys(part.input).length === 0
|
||||
) {
|
||||
console.warn(
|
||||
`[route.ts] Filtering out tool-call with invalid input:`,
|
||||
{ toolName: part.toolName, input: part.input },
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return { ...msg, content: filteredContent }
|
||||
})
|
||||
.filter((msg: any) => msg.content && msg.content.length > 0)
|
||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||
|
||||
// DEBUG: Log modelMessages structure (what's being sent to AI)
|
||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] ModelMsg ${idx} role:`,
|
||||
msg.role,
|
||||
"content count:",
|
||||
msg.content?.length,
|
||||
)
|
||||
if (msg.content) {
|
||||
msg.content.forEach((part: any, partIdx: number) => {
|
||||
if (part.type === "tool-call" || part.type === "tool-result") {
|
||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputValue:
|
||||
part.input === undefined
|
||||
? "undefined"
|
||||
: part.input === null
|
||||
? "null"
|
||||
: "object",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||
if (DEBUG_LLM_PAYLOAD) {
|
||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
||||
console.log(
|
||||
`[route.ts] ModelMsg ${idx} role:`,
|
||||
msg.role,
|
||||
"content count:",
|
||||
msg.content?.length,
|
||||
)
|
||||
if (msg.content) {
|
||||
msg.content.forEach((part: any, partIdx: number) => {
|
||||
if (
|
||||
part.type === "tool-call" ||
|
||||
part.type === "tool-result"
|
||||
) {
|
||||
console.log(`[route.ts] Content ${partIdx}:`, {
|
||||
type: part.type,
|
||||
toolName: part.toolName,
|
||||
hasInput: !!part.input,
|
||||
inputType: typeof part.input,
|
||||
inputValue:
|
||||
part.input === undefined
|
||||
? "undefined"
|
||||
: part.input === null
|
||||
? "null"
|
||||
: "object",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update the last message with user input only (XML moved to separate cached system message)
|
||||
if (enhancedMessages.length >= 1) {
|
||||
@@ -395,7 +469,7 @@ ${userInputText}
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType,
|
||||
mediaType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,9 +489,7 @@ ${userInputText}
|
||||
if (enhancedMessages[i].role === "assistant") {
|
||||
enhancedMessages[i] = {
|
||||
...enhancedMessages[i],
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
providerOptions: CACHE_POINT,
|
||||
}
|
||||
break // Only cache the last assistant message
|
||||
}
|
||||
@@ -442,6 +514,7 @@ ${userInputText}
|
||||
const isSingleSystemProvider =
|
||||
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
|
||||
|
||||
const selectionContext = formatSelectionContext(body.selectedCells)
|
||||
const xmlContext = `${
|
||||
previousXml
|
||||
? `Previous diagram XML (before user's last message):
|
||||
@@ -456,7 +529,7 @@ ${previousXml}
|
||||
${xml || ""}
|
||||
"""
|
||||
|
||||
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
|
||||
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
|
||||
|
||||
const systemMessages = isSingleSystemProvider
|
||||
? [
|
||||
@@ -470,32 +543,30 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
{
|
||||
role: "system" as const,
|
||||
content: finalSystemMessage,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||
{
|
||||
role: "system" as const,
|
||||
content: xmlContext,
|
||||
...(shouldCache && {
|
||||
providerOptions: {
|
||||
bedrock: { cachePoint: { type: "default" } },
|
||||
},
|
||||
}),
|
||||
...(shouldCache && { providerOptions: CACHE_POINT }),
|
||||
},
|
||||
]
|
||||
|
||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||
|
||||
// Set by onAbort, which records the finished steps' tokens itself
|
||||
let stopped = false
|
||||
const result = streamText({
|
||||
model,
|
||||
// The system messages carry cache points, so they go in messages.
|
||||
// A client's own system messages have string content and were
|
||||
// dropped by the empty-content filter above.
|
||||
allowSystemInMessages: true,
|
||||
abortSignal: req.signal,
|
||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||
}),
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
maxOutputTokens,
|
||||
stopWhen: stepCountIs(5),
|
||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||
@@ -513,23 +584,11 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
error.name === "AI_InvalidToolInputError"
|
||||
) {
|
||||
try {
|
||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
|
||||
let inputToRepair = toolCall.input
|
||||
if (typeof inputToRepair === "string") {
|
||||
// Fix `:=` instead of `: ` (LLM sometimes generates this)
|
||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||
// Fix `= "` instead of `: "`
|
||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
||||
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
||||
// Example: y="-20\" should be y=\"-20\"
|
||||
inputToRepair = inputToRepair.replace(
|
||||
/(\w+)="([^"]*?)\\"/g,
|
||||
'$1=\\"$2\\"',
|
||||
)
|
||||
}
|
||||
// Use jsonrepair to fix truncated JSON
|
||||
const repairedInput = jsonrepair(inputToRepair)
|
||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
|
||||
// then use jsonrepair to fix truncated JSON
|
||||
const repairedInput = jsonrepair(
|
||||
fixToolInputJson(toolCall.input),
|
||||
)
|
||||
console.log(
|
||||
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
|
||||
)
|
||||
@@ -539,26 +598,8 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
|
||||
repairError,
|
||||
)
|
||||
// Return a placeholder input to avoid API errors in multi-step
|
||||
// The tool will fail gracefully on client side
|
||||
if (toolCall.toolName === "edit_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
operations: [],
|
||||
_error: "JSON repair failed - no operations to apply",
|
||||
},
|
||||
}
|
||||
}
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
xml: "",
|
||||
_error: "JSON repair failed - empty diagram",
|
||||
},
|
||||
}
|
||||
}
|
||||
// Keep the original error, so the model and the client see why
|
||||
// the input was rejected and the model can retry the call
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -567,7 +608,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||
...(headers && { headers }),
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({
|
||||
@@ -581,21 +621,36 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// Include all 4 token types: input, output, cache read, cache write
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!hasOwnApiKey &&
|
||||
userId !== "anonymous" &&
|
||||
totalUsage
|
||||
) {
|
||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||
if (countsQuota && totalUsage && !stopped) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0) +
|
||||
(totalUsage.cachedInputTokens || 0) +
|
||||
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
||||
(totalUsage.outputTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
},
|
||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||
onError: ({ error }) => {
|
||||
console.error(error) // what AI SDK does without an onError
|
||||
endTrace()
|
||||
},
|
||||
onAbort: ({ steps }) => {
|
||||
stopped = true
|
||||
endTrace()
|
||||
// Stopped (or disconnected) after some steps finished: their
|
||||
// tokens were used, or stopping every request after a costly
|
||||
// first step would get around the token limits
|
||||
if (countsQuota) {
|
||||
const tokens = steps.reduce(
|
||||
(sum, step) =>
|
||||
sum +
|
||||
(step.usage.inputTokens || 0) +
|
||||
(step.usage.outputTokens || 0),
|
||||
0,
|
||||
)
|
||||
if (tokens > 0) recordTokenUsage(userId, tokens)
|
||||
}
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
@@ -610,21 +665,7 @@ VALIDATION RULES (XML will be rejected if violated):
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
${SWIMLANE_EXAMPLE}
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
@@ -702,14 +743,7 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
${SHAPE_LIBRARY_LIST}
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
@@ -720,45 +754,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Sanitize input - prevent path traversal attacks
|
||||
const sanitizedLibrary = library
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
|
||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||
}
|
||||
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"docs/shape-libraries",
|
||||
// Only known library names reach the file system
|
||||
const result = await getShapeLibrary(
|
||||
library,
|
||||
path.join(process.cwd(), "docs/shape-libraries"),
|
||||
)
|
||||
const filePath = path.join(
|
||||
baseDir,
|
||||
`${sanitizedLibrary}.md`,
|
||||
)
|
||||
|
||||
// Verify path stays within expected directory
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||
return `Invalid library path.`
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
error,
|
||||
)
|
||||
return `Error loading library "${library}". Please try again.`
|
||||
}
|
||||
return result.ok ? result.text : result.error
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -767,8 +768,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
}),
|
||||
})
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
// On the server's keys the provider's text can name its account.
|
||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||
// user's own, and EdgeOne's text is our function's explanation.
|
||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
const usage = (part as any).totalUsage
|
||||
@@ -781,63 +786,28 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
modelStreamResponses.add(response)
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper to categorize errors and return appropriate response
|
||||
// Errors before the stream starts, as JSON the chat panel reads
|
||||
function handleError(error: unknown): Response {
|
||||
console.error("Error in chat route:", error)
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
// Check for specific AI SDK error types
|
||||
if (APICallError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: error.message,
|
||||
...(isDev && {
|
||||
details: error.responseBody,
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: error.statusCode || 500 },
|
||||
)
|
||||
}
|
||||
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Authentication failed. Please check your API key.",
|
||||
...(isDev && {
|
||||
stack: error.stack,
|
||||
}),
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for other errors with safety filter
|
||||
const message =
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
const status = (error as any)?.statusCode || (error as any)?.status || 500
|
||||
|
||||
// Prevent leaking API keys, tokens, or other sensitive data
|
||||
const lowerMessage = message.toLowerCase()
|
||||
const safeMessage =
|
||||
lowerMessage.includes("key") ||
|
||||
lowerMessage.includes("token") ||
|
||||
lowerMessage.includes("sig") ||
|
||||
lowerMessage.includes("signature") ||
|
||||
lowerMessage.includes("secret") ||
|
||||
lowerMessage.includes("password") ||
|
||||
lowerMessage.includes("credential")
|
||||
? "Authentication failed. Please check your credentials."
|
||||
: message
|
||||
const classified = classifyLLMError(error)
|
||||
const status =
|
||||
(error as { statusCode?: number })?.statusCode ||
|
||||
(error as { status?: number })?.status ||
|
||||
(classified.code === "invalid_api_key" ? 401 : 500)
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: safeMessage,
|
||||
...classified,
|
||||
...(isDev && {
|
||||
details: message,
|
||||
details: APICallError.isInstance(error)
|
||||
? error.responseBody
|
||||
: undefined,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}),
|
||||
},
|
||||
@@ -847,11 +817,16 @@ function handleError(error: unknown): Response {
|
||||
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
let response: Response
|
||||
try {
|
||||
return await handleChatRequest(req)
|
||||
response = await handleChatRequest(req)
|
||||
} catch (error) {
|
||||
return handleError(error)
|
||||
response = handleError(error)
|
||||
}
|
||||
// Early returns, cache hits and errors never reach streamText's callbacks,
|
||||
// so their Langfuse trace has to be ended here
|
||||
if (!modelStreamResponses.has(response)) endTrace()
|
||||
return response
|
||||
}
|
||||
|
||||
// Wrap with Langfuse observe (if configured)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
|
||||
|
||||
const saveSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
format: z.enum(["drawio", "png", "svg"]),
|
||||
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
|
||||
sessionId: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { readLimitedBody } from "@/lib/read-limited-body"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||
|
||||
@@ -33,6 +36,11 @@ function detectCharset(
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
|
||||
@@ -97,7 +105,15 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES)
|
||||
if (!buffer) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
||||
},
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
@@ -115,6 +131,9 @@ export async function POST(req: Request) {
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
// Ends a download left unread (too large, PDF, error status);
|
||||
// a body already read is not affected
|
||||
controller.abort()
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import {
|
||||
canListModels,
|
||||
listProviderModels,
|
||||
ModelListError,
|
||||
} from "@/lib/provider-models"
|
||||
import {
|
||||
allowPrivateUrls,
|
||||
isPrivateUrl,
|
||||
RedirectRefusedError,
|
||||
redirectGuardedFetch,
|
||||
} from "@/lib/ssrf-protection"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
// Public lists need no key
|
||||
const NO_KEY_NEEDED = new Set<ProviderName>([
|
||||
"ollama",
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
])
|
||||
|
||||
/**
|
||||
* The models a provider offers, for the "Fetch models" button in model
|
||||
* settings. Answers { models: null } for providers that cannot list them,
|
||||
* so the dialog keeps its suggested models.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Sends requests to a URL the client chose, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
const { provider, apiKey, baseUrl } = (await req.json()) as {
|
||||
provider: ProviderName
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
}
|
||||
if (!canListModels(provider)) {
|
||||
return NextResponse.json({ models: null })
|
||||
}
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
|
||||
}
|
||||
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await listProviderModels(
|
||||
provider,
|
||||
{ apiKey, baseUrl },
|
||||
(baseUrl && redirectGuardedFetch()) || fetch,
|
||||
)
|
||||
return NextResponse.json({ models })
|
||||
} catch (error) {
|
||||
console.warn("[provider-models] Listing failed:", error)
|
||||
// Only our own explanations go back: the URL may be an internal
|
||||
// address, whose answer or host names must not reach the caller.
|
||||
// The Gateway SDK wraps them, keeping ours as the cause.
|
||||
const isOwn = (e: unknown): e is Error =>
|
||||
e instanceof ModelListError || e instanceof RedirectRefusedError
|
||||
const cause = (error as { cause?: unknown })?.cause
|
||||
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
|
||||
const { code } = classifyLLMError(own ?? error)
|
||||
return NextResponse.json({
|
||||
code,
|
||||
error: own?.message ?? "The model list request failed.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,15 @@
|
||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
||||
*/
|
||||
|
||||
import { streamObject } from "ai"
|
||||
import { Output, streamText } from "ai"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
type ValidationResult,
|
||||
@@ -13,6 +20,9 @@ import {
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
|
||||
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
|
||||
|
||||
interface ValidateDiagramRequest {
|
||||
imageData: string // Base64 PNG data URL
|
||||
sessionId?: string
|
||||
@@ -25,25 +35,20 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
||||
suggestions: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming response for useObject compatibility.
|
||||
* useObject expects text stream format, not plain JSON.
|
||||
*/
|
||||
/** A fixed result in the text format useObject reads */
|
||||
function createStreamingResponse(result: ValidationResult): Response {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Stream the JSON as text (useObject parses this)
|
||||
controller.enqueue(encoder.encode(JSON.stringify(result)))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Uses the server's model credentials, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
// Check if VLM validation is enabled (default: true)
|
||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||
@@ -72,6 +77,42 @@ export async function POST(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
|
||||
return Response.json(
|
||||
{ error: "Image data too large" },
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
|
||||
// It runs the server's vision model: with the quota on, the daily
|
||||
// and per-minute token limits apply, and its tokens are counted. Not
|
||||
// the request limit, which is for chats: the day's last chat still
|
||||
// gets its check, and a check does not count as a chat.
|
||||
const userId = getUserIdFromRequest(req)
|
||||
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
|
||||
if (countsQuota) {
|
||||
const quotaCheck = await checkAndIncrementRequest(
|
||||
userId,
|
||||
{
|
||||
requests: 0,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
},
|
||||
0,
|
||||
)
|
||||
if (!quotaCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
error: quotaCheck.error,
|
||||
type: quotaCheck.type,
|
||||
used: quotaCheck.used,
|
||||
limit: quotaCheck.limit,
|
||||
},
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the validation model
|
||||
let model
|
||||
try {
|
||||
@@ -93,9 +134,9 @@ export async function POST(req: Request): Promise<Response> {
|
||||
) || 10000
|
||||
|
||||
// Stream the VLM response for useObject consumption
|
||||
const result = streamObject({
|
||||
const result = streamText({
|
||||
model,
|
||||
schema: ValidationResultSchema,
|
||||
output: Output.object({ schema: ValidationResultSchema }),
|
||||
system: VALIDATION_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
@@ -114,10 +155,17 @@ export async function POST(req: Request): Promise<Response> {
|
||||
],
|
||||
maxOutputTokens: 1024,
|
||||
abortSignal: AbortSignal.timeout(timeout),
|
||||
onFinish: ({ object }) => {
|
||||
if (sessionId && object) {
|
||||
onFinish: ({ output, totalUsage }) => {
|
||||
if (countsQuota && totalUsage) {
|
||||
recordTokenUsage(
|
||||
userId,
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0),
|
||||
)
|
||||
}
|
||||
if (sessionId && output) {
|
||||
console.log(
|
||||
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
|
||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+155
-368
@@ -1,27 +1,28 @@
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
|
||||
import { createGateway } from "@ai-sdk/gateway"
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||
import { createVertex } from "@ai-sdk/google-vertex"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { streamText, tool } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
normalizeMiniMaxBaseURL,
|
||||
edgeOneEndpoint,
|
||||
getAIModel,
|
||||
globalBaseUrl,
|
||||
usesServerCredentials,
|
||||
usesServerEndpoint,
|
||||
} from "@/lib/ai-providers"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import { classifyLLMError } from "@/lib/llm-errors"
|
||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface ValidateRequest {
|
||||
provider: string
|
||||
provider: ProviderName
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
modelId: string
|
||||
@@ -29,21 +30,42 @@ interface ValidateRequest {
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
awsSessionToken?: string
|
||||
// Vertex AI specific
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
|
||||
serverBaseUrl?: boolean
|
||||
}
|
||||
|
||||
const TEST_TIMEOUT_MS = 15_000
|
||||
|
||||
// Drawing works through tool calls, so the test asks for one
|
||||
const PING_TOOL = tool({
|
||||
description: "Report that the connection works.",
|
||||
inputSchema: z.object({}),
|
||||
})
|
||||
|
||||
const NO_TOOL_CALL_WARNING =
|
||||
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const crossSite = rejectCrossSite(req)
|
||||
if (crossSite) return crossSite
|
||||
// Lets the server send requests to arbitrary URLs, so require the access
|
||||
// code, or the admin password (the admin panel's Test button)
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError && checkAdminAuth(req)) return accessError
|
||||
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
modelId,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
awsSessionToken,
|
||||
// Note: Express Mode only needs vertexApiKey
|
||||
vertexApiKey,
|
||||
} = body
|
||||
@@ -54,9 +76,26 @@ export async function POST(req: Request) {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// EdgeOne is this site's own function, as in the chat; the admin
|
||||
// panel's Test sends no URL, and a relative one cannot be fetched
|
||||
const baseUrl =
|
||||
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
|
||||
// The admin panel's Test of an entry without a URL sends the
|
||||
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
|
||||
// user chose, so no private-address or redirect rules
|
||||
const serverUrl =
|
||||
body.serverBaseUrl === true &&
|
||||
!!baseUrl &&
|
||||
baseUrl === globalBaseUrl(provider) &&
|
||||
!checkAdminAuth(req)
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
if (
|
||||
baseUrl &&
|
||||
!serverUrl &&
|
||||
!allowPrivateUrls() &&
|
||||
(await isPrivateUrl(baseUrl))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -90,369 +129,117 @@ export async function POST(req: Request) {
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let model: any
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "anthropic": {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||
})
|
||||
model = anthropic(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = google(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "vertexai": {
|
||||
const vertex = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = vertex(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "azure": {
|
||||
const azure = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = azure.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "bedrock": {
|
||||
const bedrock = createAmazonBedrock({
|
||||
accessKeyId: awsAccessKeyId,
|
||||
secretAccessKey: awsSecretAccessKey,
|
||||
region: awsRegion,
|
||||
})
|
||||
model = bedrock(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "openrouter": {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "aihubmix": {
|
||||
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
|
||||
|
||||
if (
|
||||
isAihubmixStandardBaseURL(baseUrl) ||
|
||||
baseUrl === defaultBaseURL
|
||||
) {
|
||||
const aihubmix = createAihubmix({
|
||||
apiKey,
|
||||
appCode: AIHUBMIX_APP_CODE,
|
||||
})
|
||||
model = aihubmix(modelId)
|
||||
} else {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek": {
|
||||
if (baseUrl || apiKey) {
|
||||
const ds = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = ds(modelId)
|
||||
} else {
|
||||
model = deepseek(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "siliconflow": {
|
||||
const sf = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
// SECURITY: Mirror ai-providers.ts guard — only use server
|
||||
// OLLAMA_API_KEY when the URL is also from server config.
|
||||
const ollamaApiKey = baseUrl
|
||||
? apiKey || undefined
|
||||
: apiKey || process.env.OLLAMA_API_KEY || undefined
|
||||
const ollamaProvider = createOllama({
|
||||
baseURL:
|
||||
baseUrl ||
|
||||
process.env.OLLAMA_BASE_URL ||
|
||||
"https://ollama.com/api",
|
||||
...(ollamaApiKey && {
|
||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||
}),
|
||||
})
|
||||
model = ollamaProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "gateway": {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "edgeone": {
|
||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
||||
// Need to pass cookies for EdgeOne Pages authentication
|
||||
const cookieHeader = req.headers.get("cookie") || ""
|
||||
const edgeone = createOpenAI({
|
||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
||||
baseURL: baseUrl || "/api/edgeai",
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
},
|
||||
})
|
||||
model = edgeone.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "sglang": {
|
||||
// SGLang is OpenAI-compatible
|
||||
const sglang = createOpenAI({
|
||||
apiKey: apiKey || "not-needed",
|
||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||
})
|
||||
model = sglang.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "doubao": {
|
||||
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||
const doubaoBaseUrl =
|
||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
if (
|
||||
lowerModelId.includes("deepseek") ||
|
||||
lowerModelId.includes("kimi")
|
||||
) {
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
})
|
||||
model = doubao(modelId)
|
||||
} else {
|
||||
const doubao = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
})
|
||||
model = doubao.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "modelscope": {
|
||||
const baseURL =
|
||||
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||
const response = await fetch(
|
||||
`${baseURL}/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: "user", content: "Say 'OK'" },
|
||||
],
|
||||
max_tokens: 20,
|
||||
stream: true,
|
||||
enable_thinking: false,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`ModelScope API error (${response.status}): ${errorText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") || ""
|
||||
const isValidStreamingResponse =
|
||||
response.status === 200 &&
|
||||
(contentType.includes("text/event-stream") ||
|
||||
contentType.includes("application/json"))
|
||||
|
||||
if (!isValidStreamingResponse) {
|
||||
throw new Error(
|
||||
`Unexpected response format: ${contentType}`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
if (response.body) {
|
||||
response.body.cancel().catch(() => {
|
||||
/* Ignore cancellation errors */
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
note: "ModelScope model validated (using streaming API)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[validate-model] ModelScope validation failed:",
|
||||
error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
case "minimax": {
|
||||
const rawUrl =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
||||
"https://api.minimaxi.com/anthropic"
|
||||
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
||||
normalizeMiniMaxBaseURL(rawUrl)
|
||||
|
||||
if (isAnthropicCompatible) {
|
||||
const minimax = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
} else {
|
||||
const minimax = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
""
|
||||
|
||||
if (!baseURL) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
error: `No base URL configured for provider: ${provider}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
// The Test button checks the user's own provider. On the server's
|
||||
// keys (Ollama Cloud without a key or URL) anyone could run any model.
|
||||
if (
|
||||
usesServerCredentials(provider, {
|
||||
apiKey,
|
||||
baseUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
vertexApiKey,
|
||||
})
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "API key is required" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Make a minimal test request
|
||||
const startTime = Date.now()
|
||||
await generateText({
|
||||
model,
|
||||
prompt: "Say 'OK'",
|
||||
maxOutputTokens: 20,
|
||||
// On the deployment's own endpoints a Test runs a model as a chat
|
||||
// does, so with the quota on it counts as a chat request (an
|
||||
// admin's Test of the server's URL does not)
|
||||
const userId = getUserIdFromRequest(req)
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!serverUrl &&
|
||||
userId !== "anonymous" &&
|
||||
(await usesServerEndpoint(
|
||||
provider,
|
||||
normalizeBaseUrl(body.baseUrl ?? ""),
|
||||
apiKey,
|
||||
))
|
||||
) {
|
||||
const quotaCheck = await checkAndIncrementRequest(userId, {
|
||||
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
|
||||
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
|
||||
tpm: Number(process.env.TPM_LIMIT) || 20000,
|
||||
})
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: quotaCheck.error },
|
||||
{ status: 429 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The same model the chat would use. A client base URL makes it
|
||||
// refuse redirects to internal hosts.
|
||||
const { model } = getAIModel({
|
||||
provider,
|
||||
modelId,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
trustedBaseUrl: serverUrl,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
awsRegion,
|
||||
// Temporary AWS credentials need it, as in the chat
|
||||
awsSessionToken,
|
||||
vertexApiKey,
|
||||
// EdgeOne checks the Pages cookies and the access code
|
||||
...(provider === "edgeone" && {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") || "",
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Streaming, like the chat (some models only stream). Stop at the
|
||||
// first tool call; a reasoning model that runs out of tokens first
|
||||
// proves the connection but not tool support.
|
||||
const startTime = Date.now()
|
||||
const result = streamText({
|
||||
model,
|
||||
prompt: "Call the ping tool.",
|
||||
tools: { ping: PING_TOOL },
|
||||
maxOutputTokens: 1024,
|
||||
maxRetries: 0,
|
||||
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
})
|
||||
let calledTool = false
|
||||
let finishReason: string | undefined
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") throw part.error
|
||||
// The timeout ends the stream with an abort part, not an error
|
||||
if (part.type === "abort") {
|
||||
const timeout = new Error(
|
||||
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
|
||||
)
|
||||
timeout.name = "TimeoutError"
|
||||
throw timeout
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
calledTool = true
|
||||
break
|
||||
}
|
||||
if (part.type === "finish") finishReason = part.finishReason
|
||||
}
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
responseTime,
|
||||
...(!calledTool &&
|
||||
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[validate-model] Error:", error)
|
||||
|
||||
let errorMessage = "Validation failed"
|
||||
if (error instanceof Error) {
|
||||
// Extract meaningful error message
|
||||
if (
|
||||
error.message.includes("401") ||
|
||||
error.message.includes("Unauthorized")
|
||||
) {
|
||||
errorMessage = "Invalid API key"
|
||||
} else if (
|
||||
error.message.includes("404") ||
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
errorMessage = "Model not found"
|
||||
} else if (
|
||||
error.message.includes("429") ||
|
||||
error.message.includes("rate limit")
|
||||
) {
|
||||
errorMessage = "Rate limited - try again later"
|
||||
} else if (error.message.includes("ECONNREFUSED")) {
|
||||
errorMessage = "Cannot connect to server"
|
||||
} else {
|
||||
errorMessage = error.message.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const { code, message } = classifyLLMError(error)
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: errorMessage },
|
||||
{ valid: false, code, error: message },
|
||||
{ status: 200 }, // Return 200 so client can read error message
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
// If no access codes configured, verification always passes
|
||||
if (accessCodes.length === 0) {
|
||||
return Response.json({
|
||||
valid: true,
|
||||
message: "No access code required",
|
||||
})
|
||||
}
|
||||
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
|
||||
if (!accessCodeHeader) {
|
||||
if (checkAccessCode(req)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Access code is required" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid access code" },
|
||||
{ valid: false, message: "Invalid or missing access code" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
+260
-242
@@ -8,7 +8,7 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-sans: var(--font-latin), var(--font-cjk), system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
@@ -43,157 +43,13 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Clean Light Modern Palette */
|
||||
--background: oklch(0.985 0.002 240);
|
||||
--foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.23 0.02 260);
|
||||
|
||||
/* Dark primary - slightly lighter */
|
||||
--primary: oklch(0.35 0.01 260);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
/* Warm gray secondary */
|
||||
--secondary: oklch(0.96 0.005 260);
|
||||
--secondary-foreground: oklch(0.35 0.02 260);
|
||||
|
||||
/* Light muted tones */
|
||||
--muted: oklch(0.965 0.005 260);
|
||||
--muted-foreground: oklch(0.5 0.02 260);
|
||||
|
||||
/* Soft lavender accent */
|
||||
--accent: oklch(0.94 0.03 280);
|
||||
--accent-foreground: oklch(0.35 0.08 270);
|
||||
|
||||
/* Muted rose destructive */
|
||||
--destructive: oklch(0.45 0.12 10);
|
||||
|
||||
/* Subtle borders */
|
||||
--border: oklch(0.92 0.01 260);
|
||||
--input: oklch(0.94 0.01 260);
|
||||
--ring: oklch(0.25 0.01 260);
|
||||
|
||||
/* Chart colors - harmonious palette */
|
||||
--chart-1: oklch(0.55 0.18 265);
|
||||
--chart-2: oklch(0.65 0.15 170);
|
||||
--chart-3: oklch(0.7 0.18 45);
|
||||
--chart-4: oklch(0.6 0.2 330);
|
||||
--chart-5: oklch(0.5 0.15 200);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0.99 0.002 260);
|
||||
--sidebar-foreground: oklch(0.23 0.02 260);
|
||||
--sidebar-primary: oklch(0.55 0.18 265);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.02 270);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.05 265);
|
||||
--sidebar-border: oklch(0.93 0.01 260);
|
||||
--sidebar-ring: oklch(0.55 0.18 265);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.15 0.015 260);
|
||||
--foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--card: oklch(0.2 0.015 260);
|
||||
--card-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--popover: oklch(0.2 0.015 260);
|
||||
--popover-foreground: oklch(0.95 0.01 260);
|
||||
|
||||
--primary: oklch(0.7 0.16 265);
|
||||
--primary-foreground: oklch(0.15 0.02 260);
|
||||
|
||||
--secondary: oklch(0.25 0.015 260);
|
||||
--secondary-foreground: oklch(0.9 0.01 260);
|
||||
|
||||
--muted: oklch(0.25 0.015 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
|
||||
--accent: oklch(0.3 0.04 280);
|
||||
--accent-foreground: oklch(0.9 0.03 270);
|
||||
|
||||
--destructive: oklch(0.55 0.12 10);
|
||||
|
||||
--border: oklch(0.28 0.015 260);
|
||||
--input: oklch(0.25 0.015 260);
|
||||
--ring: oklch(0.7 0.16 265);
|
||||
|
||||
--chart-1: oklch(0.7 0.16 265);
|
||||
--chart-2: oklch(0.7 0.13 170);
|
||||
--chart-3: oklch(0.75 0.16 45);
|
||||
--chart-4: oklch(0.7 0.18 330);
|
||||
--chart-5: oklch(0.6 0.13 200);
|
||||
|
||||
--sidebar: oklch(0.18 0.015 260);
|
||||
--sidebar-foreground: oklch(0.95 0.01 260);
|
||||
--sidebar-primary: oklch(0.7 0.16 265);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 260);
|
||||
--sidebar-accent: oklch(0.25 0.03 270);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.02 265);
|
||||
--sidebar-border: oklch(0.28 0.015 260);
|
||||
--sidebar-ring: oklch(0.7 0.16 265);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED MINIMAL DESIGN SYSTEM
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Surface layers for depth */
|
||||
--surface-0: oklch(1 0 0);
|
||||
--surface-1: oklch(0.985 0.002 240);
|
||||
--surface-2: oklch(0.97 0.004 240);
|
||||
--surface-elevated: oklch(1 0 0);
|
||||
|
||||
/* Subtle borders */
|
||||
--border-subtle: oklch(0.94 0.008 260);
|
||||
--border-default: oklch(0.91 0.012 260);
|
||||
|
||||
/* Interactive states */
|
||||
--interactive-hover: oklch(0.96 0.015 260);
|
||||
--interactive-active: oklch(0.93 0.02 265);
|
||||
|
||||
/* Success state */
|
||||
--success: oklch(0.65 0.18 145);
|
||||
--success-muted: oklch(0.95 0.03 145);
|
||||
|
||||
/* Animation timing */
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface-0: oklch(0.15 0.015 260);
|
||||
--surface-1: oklch(0.18 0.015 260);
|
||||
--surface-2: oklch(0.22 0.015 260);
|
||||
--surface-elevated: oklch(0.25 0.015 260);
|
||||
|
||||
--border-subtle: oklch(0.25 0.012 260);
|
||||
--border-default: oklch(0.3 0.015 260);
|
||||
|
||||
--interactive-hover: oklch(0.25 0.02 265);
|
||||
--interactive-active: oklch(0.3 0.025 270);
|
||||
|
||||
--success: oklch(0.7 0.16 145);
|
||||
--success-muted: oklch(0.25 0.04 145);
|
||||
}
|
||||
|
||||
/* Expose surface colors to Tailwind */
|
||||
@theme inline {
|
||||
/* App-specific tokens */
|
||||
--color-canvas: var(--canvas);
|
||||
--color-faint: var(--faint);
|
||||
--color-marker: var(--marker);
|
||||
--color-marker-soft: var(--marker-soft);
|
||||
--color-marker-ink: var(--marker-ink);
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
@@ -206,12 +62,185 @@
|
||||
--color-success-muted: var(--success-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* Light: a quiet light table. Ink for primary actions, a highlighter
|
||||
* yellow reserved for what the AI just changed.
|
||||
*/
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
|
||||
--canvas: #f4f5f7;
|
||||
--background: #f4f5f7;
|
||||
--foreground: #14181d;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #14181d;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #14181d;
|
||||
|
||||
--primary: #14181d;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f1f3f5;
|
||||
--secondary-foreground: #14181d;
|
||||
--muted: #f1f3f5;
|
||||
--muted-foreground: #5d6672;
|
||||
--faint: #687180;
|
||||
--accent: #eef0f3;
|
||||
--accent-foreground: #14181d;
|
||||
--destructive: #c9372c;
|
||||
|
||||
--border: #e4e7eb;
|
||||
--input: #dfe3e8;
|
||||
--ring: #14181d;
|
||||
|
||||
--marker: #ffd84d;
|
||||
--marker-soft: #fff4bf;
|
||||
--marker-ink: #6b5200;
|
||||
|
||||
--chart-1: #3b6fd8;
|
||||
--chart-2: #1f9e86;
|
||||
--chart-3: #e8892c;
|
||||
--chart-4: #c2417a;
|
||||
--chart-5: #2a8fb8;
|
||||
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #14181d;
|
||||
--sidebar-primary: #14181d;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f1f3f5;
|
||||
--sidebar-accent-foreground: #14181d;
|
||||
--sidebar-border: #e4e7eb;
|
||||
--sidebar-ring: #14181d;
|
||||
|
||||
--surface-0: #ffffff;
|
||||
--surface-1: #f7f8fa;
|
||||
--surface-2: #f1f3f5;
|
||||
--surface-elevated: #ffffff;
|
||||
--border-subtle: #eceef1;
|
||||
--border-default: #e4e7eb;
|
||||
--interactive-hover: #f1f3f5;
|
||||
--interactive-active: #e7eaee;
|
||||
--success: #1f9e5a;
|
||||
--success-muted: #e3f5ea;
|
||||
|
||||
--shadow-float:
|
||||
0 1px 2px rgb(20 24 29 / 0.05), 0 10px 30px -8px rgb(20 24 29 / 0.14);
|
||||
--shadow-pop:
|
||||
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
||||
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/*
|
||||
* Han characters are drawn differently in Simplified Chinese, Traditional
|
||||
* Chinese and Japanese: each language lists its own system fonts first.
|
||||
*/
|
||||
:root {
|
||||
--font-cjk:
|
||||
"PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC",
|
||||
"Microsoft YaHei", "Hiragino Sans", "Meiryo";
|
||||
}
|
||||
:root:lang(ja) {
|
||||
--font-cjk:
|
||||
"Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP",
|
||||
"Yu Gothic UI", "Meiryo", "PingFang SC";
|
||||
}
|
||||
:root:lang(zh-Hant) {
|
||||
--font-cjk:
|
||||
"PingFang TC", "Noto Sans CJK TC", "Microsoft JhengHei",
|
||||
"Hiragino Sans", "PingFang SC";
|
||||
}
|
||||
|
||||
/*
|
||||
* Dark: a blueprint-blue table. Surfaces get lighter the higher they float.
|
||||
* The diagram stays on a white sheet so it looks the way it will export;
|
||||
* primary actions are paper-white, and amber only marks AI changes.
|
||||
*/
|
||||
.dark {
|
||||
--canvas: #0f2440;
|
||||
--background: #0c1a29;
|
||||
--foreground: #e9f0f6;
|
||||
|
||||
--card: #16304f;
|
||||
--card-foreground: #e9f0f6;
|
||||
--popover: #1b3a5d;
|
||||
--popover-foreground: #e9f0f6;
|
||||
|
||||
--primary: #e9f0f6;
|
||||
--primary-foreground: #0c1a29;
|
||||
--secondary: #1d3b5e;
|
||||
--secondary-foreground: #e9f0f6;
|
||||
--muted: #1d3b5e;
|
||||
--muted-foreground: #a8bbce;
|
||||
--faint: #8aa0b6;
|
||||
--accent: #21426a;
|
||||
--accent-foreground: #e9f0f6;
|
||||
--destructive: #f07167;
|
||||
|
||||
--border: rgb(170 200 230 / 0.16);
|
||||
--input: rgb(170 200 230 / 0.24);
|
||||
--ring: #e9f0f6;
|
||||
|
||||
--marker: #f5b13d;
|
||||
--marker-soft: rgb(245 177 61 / 0.16);
|
||||
--marker-ink: #f7c46b;
|
||||
|
||||
--chart-1: #7aa2ff;
|
||||
--chart-2: #4fd1b4;
|
||||
--chart-3: #f5b13d;
|
||||
--chart-4: #f08bb4;
|
||||
--chart-5: #5cc3ea;
|
||||
|
||||
--sidebar: #16304f;
|
||||
--sidebar-foreground: #e9f0f6;
|
||||
--sidebar-primary: #e9f0f6;
|
||||
--sidebar-primary-foreground: #0c1a29;
|
||||
--sidebar-accent: #1d3b5e;
|
||||
--sidebar-accent-foreground: #e9f0f6;
|
||||
--sidebar-border: rgb(170 200 230 / 0.16);
|
||||
--sidebar-ring: #e9f0f6;
|
||||
|
||||
--surface-0: #16304f;
|
||||
--surface-1: #132b48;
|
||||
--surface-2: #1d3b5e;
|
||||
--surface-elevated: #1b3a5d;
|
||||
--border-subtle: rgb(170 200 230 / 0.12);
|
||||
--border-default: rgb(170 200 230 / 0.2);
|
||||
--interactive-hover: #21426a;
|
||||
--interactive-active: #264a75;
|
||||
--success: #4fd18b;
|
||||
--success-muted: rgb(79 209 139 / 0.14);
|
||||
|
||||
--shadow-float:
|
||||
0 0 0 1px rgb(170 200 230 / 0.1), 0 14px 34px -12px rgb(0 0 0 / 0.55);
|
||||
--shadow-pop:
|
||||
0 0 0 1px rgb(170 200 230 / 0.14), 0 22px 56px -14px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection {
|
||||
background: var(--marker-soft);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,49 +250,61 @@
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0.85 0.01 260) transparent;
|
||||
scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
|
||||
transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.85 0.01 260);
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--foreground) 18%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.75 0.01 260);
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--foreground) 30%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: oklch(0.35 0.015 260) transparent;
|
||||
/* Diagram pictures always render as on paper, also in dark mode
|
||||
(draw.io SVGs use light-dark() colors) */
|
||||
.sheet-light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.35 0.015 260);
|
||||
.shadow-float {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.45 0.015 260);
|
||||
.shadow-pop {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
/* Older names kept for components that still use them */
|
||||
.shadow-soft {
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.shadow-soft-lg {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
.shadow-dialog {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -282,83 +323,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Message bubble animations */
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-message-in {
|
||||
animation: messageIn 0.25s ease-out forwards;
|
||||
/* The first prompt moves from the middle of the canvas into the panel */
|
||||
@keyframes panelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle floating shadow for cards */
|
||||
.shadow-soft {
|
||||
box-shadow:
|
||||
0 1px 2px oklch(0.23 0.02 260 / 0.04),
|
||||
0 4px 12px oklch(0.23 0.02 260 / 0.06),
|
||||
0 8px 24px oklch(0.23 0.02 260 / 0.04);
|
||||
@keyframes heroOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.shadow-soft-lg {
|
||||
box-shadow:
|
||||
0 2px 4px oklch(0.23 0.02 260 / 0.04),
|
||||
0 8px 20px oklch(0.23 0.02 260 / 0.08),
|
||||
0 16px 40px oklch(0.23 0.02 260 / 0.06);
|
||||
}
|
||||
|
||||
/* Gradient text utility */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.55 0.18 265),
|
||||
oklch(0.6 0.2 290)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REFINED DIALOG STYLES
|
||||
============================================ */
|
||||
|
||||
/* Refined dialog shadow - multi-layer soft shadow */
|
||||
.shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(0 0 0 / 0.03),
|
||||
0 2px 4px oklch(0 0 0 / 0.02),
|
||||
0 12px 24px oklch(0 0 0 / 0.06),
|
||||
0 24px 48px oklch(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.dark .shadow-dialog {
|
||||
box-shadow:
|
||||
0 0 0 1px oklch(1 0 0 / 0.05),
|
||||
0 2px 4px oklch(0 0 0 / 0.2),
|
||||
0 12px 24px oklch(0 0 0 / 0.3),
|
||||
0 24px 48px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* Dialog animations */
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -373,19 +375,10 @@
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
transform: translate(-50%, -48%) scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dialog-in {
|
||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.animate-dialog-out {
|
||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
/* Check pop animation for validation success */
|
||||
@keyframes check-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
@@ -400,12 +393,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.25s var(--ease-out) forwards;
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s var(--ease-out) forwards;
|
||||
}
|
||||
.animate-message-in {
|
||||
animation: messageIn 0.22s var(--ease-out) forwards;
|
||||
}
|
||||
.animate-panel-in {
|
||||
animation: panelIn 0.32s var(--ease-out) both;
|
||||
}
|
||||
.animate-hero-out {
|
||||
animation: heroOut 0.2s var(--ease-out) forwards;
|
||||
}
|
||||
.animate-dialog-in {
|
||||
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
|
||||
}
|
||||
.animate-dialog-out {
|
||||
animation: dialog-out 150ms var(--ease-out) forwards;
|
||||
}
|
||||
.animate-check-pop {
|
||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in,
|
||||
.animate-slide-in-right,
|
||||
.animate-message-in,
|
||||
.animate-panel-in,
|
||||
.animate-hero-out,
|
||||
.animate-dialog-in,
|
||||
.animate-dialog-out,
|
||||
.animate-check-pop {
|
||||
|
||||
+6
-1
@@ -7,7 +7,12 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public/**/*.svg"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"
|
||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||
import { PageTabs } from "@/components/canvas/page-tabs"
|
||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getDrawioCss, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
||||
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/** File types the "Open .drawio file" picker accepts */
|
||||
export const DIAGRAM_FILE_ACCEPT =
|
||||
".drawio,.xml,.svg,application/xml,image/svg+xml"
|
||||
|
||||
/**
|
||||
* The draw.io canvas with the app's own toolbar, page tabs and the
|
||||
* "ask AI" button laid over it. With an external (cross-origin) draw.io the
|
||||
* overlays are hidden and draw.io's own toolbar is used.
|
||||
*/
|
||||
export function CanvasStage({
|
||||
className,
|
||||
style,
|
||||
compact = false,
|
||||
hideOverlays = false,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
compact?: boolean
|
||||
/** Hide toolbar and buttons (e.g. while the start screen covers the canvas) */
|
||||
hideOverlays?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const params = useParams<{ lang: string }>()
|
||||
const lang = (
|
||||
i18n.locales.includes(params.lang as Locale)
|
||||
? params.lang
|
||||
: i18n.defaultLocale
|
||||
) as Locale
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramAutoSave,
|
||||
handleDiagramExport,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
isDrawioReady,
|
||||
} = useDiagram()
|
||||
const engine = useChatEngine()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
||||
// The model sees the first page only, so asking about shapes elsewhere
|
||||
// would change the wrong page
|
||||
const onFirstPage = useCanvasStore(
|
||||
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
|
||||
)
|
||||
// Canvas width before the chat panel slides (React passes no
|
||||
// transitionstart, so this is the width after the previous slide)
|
||||
const settledWidthRef = useRef(0)
|
||||
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
// null until draw.io tells us whether we can drive it directly
|
||||
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
||||
// Bumped to reload draw.io when its CSS can only change with a reload
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
setSrc(getDrawioSrc({ lang, isElectron }))
|
||||
}, [lang])
|
||||
|
||||
// A new iframe starts over: draw.io reports load and access again
|
||||
useEffect(() => {
|
||||
resetDrawioReady()
|
||||
setEditorAccess(null)
|
||||
}, [src, reloadKey])
|
||||
|
||||
// Cross-origin draw.io only takes CSS when it starts, so reload it when
|
||||
// the theme changes. Same-origin updates the CSS in place.
|
||||
const lastDarkRef = useRef(isDark)
|
||||
useEffect(() => {
|
||||
if (lastDarkRef.current === isDark) return
|
||||
lastDarkRef.current = isDark
|
||||
if (editorAccess === false) setReloadKey((key) => key + 1)
|
||||
}, [isDark, editorAccess])
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
settledWidthRef.current = stage.clientWidth
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
})
|
||||
refitIfRecent()
|
||||
})
|
||||
observer.observe(stage)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
|
||||
// The full toolbar is about 590 px wide; a narrower canvas (small window
|
||||
// with the chat panel open) gets the short one
|
||||
const compactToolbar = compact || (size.width > 0 && size.width < 640)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={cn("absolute overflow-hidden bg-canvas", className)}
|
||||
style={style}
|
||||
data-testid="canvas-stage"
|
||||
// The chat panel slides in or out (the canvas's right edge moves):
|
||||
// once the canvas has its final size, fit a diagram just drawn,
|
||||
// or keep the view's middle
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.propertyName !== "right") return
|
||||
const stage = event.currentTarget
|
||||
// Let draw.io take the new size first
|
||||
setTimeout(() => {
|
||||
const before = settledWidthRef.current
|
||||
settledWidthRef.current = stage.clientWidth
|
||||
if (refitIfRecent(4000)) return
|
||||
keepCenter(before, stage.clientWidth)
|
||||
}, 60)
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<DrawioFrame
|
||||
key={`${src}#${reloadKey}`}
|
||||
ref={drawioRef}
|
||||
src={src}
|
||||
configCss={getDrawioCss({
|
||||
dark: isDark,
|
||||
customChrome: false,
|
||||
})}
|
||||
editorCss={getDrawioCss({
|
||||
dark: isDark,
|
||||
customChrome: true,
|
||||
})}
|
||||
onLoad={onDrawioLoad}
|
||||
onAutoSave={handleDiagramAutoSave}
|
||||
onExport={handleDiagramExport}
|
||||
onEditorAccess={setEditorAccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Covers the iframe until draw.io has loaded and been styled */}
|
||||
{(!isDrawioReady || editorAccess === null) && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-canvas"
|
||||
data-testid="canvas-loading"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
|
||||
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
|
||||
{dict.workspace.canvasLoading}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverlays && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-3 z-10 flex justify-center px-3">
|
||||
<CanvasToolbar
|
||||
compact={compactToolbar}
|
||||
onOpenFile={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
{/* Full width, so the tabs' max width is the canvas's */}
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex">
|
||||
<PageTabs />
|
||||
</div>
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy || !onFirstPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={DIAGRAM_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) engine.openDiagramFile(file)
|
||||
event.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
Ellipsis,
|
||||
Maximize2,
|
||||
Minus,
|
||||
MousePointer2,
|
||||
Paintbrush,
|
||||
PenLine,
|
||||
Plus,
|
||||
Redo2,
|
||||
Shapes,
|
||||
Spline,
|
||||
Type,
|
||||
Undo2,
|
||||
} from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { IconButton, ToolbarDivider } from "@/components/workspace/icon-button"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
fitDiagram,
|
||||
hasAction,
|
||||
runAction,
|
||||
stopFreehand,
|
||||
toggleShapesPanel,
|
||||
viewToggles,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { isMac, modKey } from "@/lib/platform"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
const mod = modKey
|
||||
|
||||
interface MenuItem {
|
||||
action: string
|
||||
label: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
function MenuButton({
|
||||
children,
|
||||
onClick,
|
||||
shortcut,
|
||||
checked,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
shortcut?: string
|
||||
/** On/off items: a check mark when on, and room for it when off */
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...(checked !== undefined && {
|
||||
role: "menuitemcheckbox",
|
||||
"aria-checked": checked,
|
||||
})}
|
||||
className="flex w-full items-center justify-between gap-6 rounded-md px-2.5 py-1.5 text-left text-[13px] text-foreground hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{checked !== undefined && (
|
||||
<Check
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
!checked && "invisible",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
{shortcut && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas toolbar. Every button calls an existing draw.io action; a
|
||||
* button whose action is missing in this draw.io build is not shown.
|
||||
*/
|
||||
export function CanvasToolbar({
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onOpenFile: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.workspace
|
||||
const canUndo = useCanvasStore((s) => s.canUndo)
|
||||
const canRedo = useCanvasStore((s) => s.canRedo)
|
||||
const zoom = useCanvasStore((s) => s.zoom)
|
||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||
const isFormatOpen = useCanvasStore((s) => s.isFormatOpen)
|
||||
const isShapesOpen = useCanvasStore((s) => s.isShapesOpen)
|
||||
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
|
||||
const engine = useChatEngine()
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [zoomOpen, setZoomOpen] = useState(false)
|
||||
|
||||
// Checked once per mount; hasAction warns about missing actions
|
||||
const available = useMemo(() => {
|
||||
const names = [
|
||||
"undo",
|
||||
"redo",
|
||||
"toggleShapes",
|
||||
"insertText",
|
||||
"insertEdge",
|
||||
"insertFreehand",
|
||||
"zoomIn",
|
||||
"zoomOut",
|
||||
"fitWindow",
|
||||
"resetView",
|
||||
"format",
|
||||
"findReplace",
|
||||
"editDiagram",
|
||||
"layers",
|
||||
"outline",
|
||||
"grid",
|
||||
"pageView",
|
||||
"insertImage",
|
||||
"insertLink",
|
||||
"selectAll",
|
||||
"print",
|
||||
]
|
||||
return new Set(names.filter((name) => hasAction(name)))
|
||||
}, [])
|
||||
|
||||
// Read when the menu opens (draw.io has no event for these)
|
||||
const toggles = moreOpen ? viewToggles() : { grid: false, pageView: false }
|
||||
const checkedOf = (action: string) =>
|
||||
action === "grid"
|
||||
? toggles.grid
|
||||
: action === "pageView"
|
||||
? toggles.pageView
|
||||
: undefined
|
||||
const moreItems: MenuItem[] = [
|
||||
{ action: "findReplace", label: t.findReplace, shortcut: `${mod}F` },
|
||||
{ action: "layers", label: t.layers, shortcut: `${mod}⇧L` },
|
||||
{ action: "outline", label: t.outline, shortcut: `${mod}⇧O` },
|
||||
{ action: "grid", label: t.toggleGrid, shortcut: `${mod}⇧G` },
|
||||
{ action: "pageView", label: t.togglePageView },
|
||||
{ action: "insertImage", label: t.insertImage },
|
||||
{ action: "insertLink", label: t.insertLink },
|
||||
{ action: "selectAll", label: t.selectAll, shortcut: `${mod}A` },
|
||||
{ action: "editDiagram", label: t.editDiagram },
|
||||
{ action: "print", label: t.print, shortcut: `${mod}P` },
|
||||
].filter((item) => available.has(item.action))
|
||||
|
||||
const tool = (action: string) => () => {
|
||||
if (isFreehand && action !== "insertFreehand") stopFreehand()
|
||||
runAction(action)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t.toolbar}
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-center gap-0.5 rounded-xl bg-card p-1 shadow-float",
|
||||
compact && "max-w-full overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
{available.has("undo") && (
|
||||
<IconButton
|
||||
label={t.undo}
|
||||
shortcut={`${mod}Z`}
|
||||
onClick={tool("undo")}
|
||||
disabled={!canUndo}
|
||||
data-testid="toolbar-undo"
|
||||
>
|
||||
<Undo2 />
|
||||
</IconButton>
|
||||
)}
|
||||
{available.has("redo") && (
|
||||
<IconButton
|
||||
label={t.redo}
|
||||
shortcut={isMac ? "⌘⇧Z" : "Ctrl+Y"}
|
||||
onClick={tool("redo")}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo2 />
|
||||
</IconButton>
|
||||
)}
|
||||
<ToolbarDivider />
|
||||
{!compact && (
|
||||
<IconButton
|
||||
label={t.select}
|
||||
active={!isFreehand}
|
||||
onClick={() => stopFreehand()}
|
||||
>
|
||||
<MousePointer2 />
|
||||
</IconButton>
|
||||
)}
|
||||
{available.has("toggleShapes") && (
|
||||
<IconButton
|
||||
label={t.shapes}
|
||||
shortcut={`${mod}⇧K`}
|
||||
active={isShapesOpen}
|
||||
onClick={() => {
|
||||
if (isFreehand) stopFreehand()
|
||||
toggleShapesPanel()
|
||||
}}
|
||||
data-testid="toolbar-shapes"
|
||||
>
|
||||
<Shapes />
|
||||
</IconButton>
|
||||
)}
|
||||
{!compact && available.has("insertText") && (
|
||||
<IconButton label={t.text} onClick={tool("insertText")}>
|
||||
<Type />
|
||||
</IconButton>
|
||||
)}
|
||||
{!compact && available.has("insertEdge") && (
|
||||
<IconButton label={t.connector} onClick={tool("insertEdge")}>
|
||||
<Spline />
|
||||
</IconButton>
|
||||
)}
|
||||
{!compact && available.has("insertFreehand") && (
|
||||
<IconButton
|
||||
label={t.freehand}
|
||||
shortcut="X"
|
||||
active={isFreehand}
|
||||
onClick={tool("insertFreehand")}
|
||||
>
|
||||
<PenLine />
|
||||
</IconButton>
|
||||
)}
|
||||
<ToolbarDivider />
|
||||
{!compact && available.has("zoomOut") && (
|
||||
<IconButton
|
||||
label={t.zoomOut}
|
||||
shortcut={`${mod}−`}
|
||||
onClick={tool("zoomOut")}
|
||||
>
|
||||
<Minus />
|
||||
</IconButton>
|
||||
)}
|
||||
<Popover open={zoomOpen} onOpenChange={setZoomOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.zoomMenu}
|
||||
className="h-8 min-w-12 rounded-lg px-1.5 text-xs tabular-nums text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{zoom}%
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="center">
|
||||
{available.has("fitWindow") && (
|
||||
<MenuButton
|
||||
shortcut={`${mod}⇧H`}
|
||||
onClick={() => {
|
||||
runAction("fitWindow")
|
||||
setZoomOpen(false)
|
||||
}}
|
||||
>
|
||||
{t.fitWindow}
|
||||
</MenuButton>
|
||||
)}
|
||||
{available.has("resetView") && (
|
||||
<MenuButton
|
||||
shortcut={`${mod}H`}
|
||||
onClick={() => {
|
||||
runAction("resetView")
|
||||
setZoomOpen(false)
|
||||
}}
|
||||
>
|
||||
{t.zoomReset}
|
||||
</MenuButton>
|
||||
)}
|
||||
{compact && available.has("zoomIn") && (
|
||||
<MenuButton onClick={() => runAction("zoomIn")}>
|
||||
{t.zoomIn}
|
||||
</MenuButton>
|
||||
)}
|
||||
{compact && available.has("zoomOut") && (
|
||||
<MenuButton onClick={() => runAction("zoomOut")}>
|
||||
{t.zoomOut}
|
||||
</MenuButton>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!compact && available.has("zoomIn") && (
|
||||
<IconButton
|
||||
label={t.zoomIn}
|
||||
shortcut={`${mod}+`}
|
||||
onClick={tool("zoomIn")}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
)}
|
||||
{!compact && (
|
||||
<IconButton
|
||||
label={t.fitWindow}
|
||||
onClick={() => fitDiagram()}
|
||||
data-testid="toolbar-fit"
|
||||
>
|
||||
<Maximize2 />
|
||||
</IconButton>
|
||||
)}
|
||||
<ToolbarDivider />
|
||||
{available.has("format") && (
|
||||
<IconButton
|
||||
label={t.style}
|
||||
shortcut={`${mod}⇧P`}
|
||||
active={isFormatOpen}
|
||||
onClick={tool("format")}
|
||||
data-testid="toolbar-style"
|
||||
>
|
||||
<Paintbrush />
|
||||
</IconButton>
|
||||
)}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton label={t.more} data-testid="toolbar-more">
|
||||
<Ellipsis />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-60 p-1" align="end">
|
||||
<MenuButton
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => {
|
||||
setMoreOpen(false)
|
||||
onOpenFile()
|
||||
}}
|
||||
>
|
||||
{t.openFile}
|
||||
</MenuButton>
|
||||
{compact && available.has("insertText") && (
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
setMoreOpen(false)
|
||||
runAction("insertText")
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</MenuButton>
|
||||
)}
|
||||
{compact && available.has("insertFreehand") && (
|
||||
<MenuButton
|
||||
onClick={() => {
|
||||
setMoreOpen(false)
|
||||
runAction("insertFreehand")
|
||||
}}
|
||||
>
|
||||
{t.freehand}
|
||||
</MenuButton>
|
||||
)}
|
||||
<div className="my-1 h-px bg-border" />
|
||||
{moreItems.map((item) => (
|
||||
<MenuButton
|
||||
key={item.action}
|
||||
checked={checkedOf(item.action)}
|
||||
shortcut={compact ? undefined : item.shortcut}
|
||||
onClick={() => {
|
||||
setMoreOpen(false)
|
||||
runAction(item.action)
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</MenuButton>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
className="ml-1 inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
data-testid="toolbar-export"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{!compact && t.export}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
|
||||
import {
|
||||
DRAWIO_CONFIG,
|
||||
EMPTY_DRAWIO_DOCUMENT,
|
||||
} from "@/lib/drawio/drawio-config"
|
||||
import {
|
||||
attachEditor,
|
||||
detachEditor,
|
||||
watchForEditorUi,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
|
||||
export interface DrawioExportEvent {
|
||||
event: "export"
|
||||
format: string
|
||||
data: string
|
||||
xml: string
|
||||
message: { message?: string; [key: string]: unknown }
|
||||
}
|
||||
|
||||
export interface DrawioFrameHandle {
|
||||
load: (data: { xml: string }) => void
|
||||
exportDiagram: (data: {
|
||||
format: string
|
||||
message?: string
|
||||
[key: string]: unknown
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface DrawioFrameProps {
|
||||
src: string
|
||||
/** CSS sent with the configure message (works cross-origin too) */
|
||||
configCss: string
|
||||
/** CSS injected once the editor is reachable directly (same origin) */
|
||||
editorCss: string
|
||||
onLoad?: () => void
|
||||
onAutoSave?: (data: { xml?: string }) => void
|
||||
onExport?: (data: DrawioExportEvent) => void
|
||||
/** Called once we know whether the editor can be driven directly */
|
||||
onEditorAccess?: (hasEditor: boolean) => void
|
||||
}
|
||||
|
||||
const STYLE_ID = "next-ai-drawio-theme"
|
||||
|
||||
/**
|
||||
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
|
||||
* protocol (same messages react-drawio used). When the iframe is same-origin
|
||||
* it also hands the editor object to editor-bridge for direct control.
|
||||
*/
|
||||
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||
function DrawioFrame(
|
||||
{
|
||||
src,
|
||||
configCss,
|
||||
editorCss,
|
||||
onLoad,
|
||||
onAutoSave,
|
||||
onExport,
|
||||
onEditorAccess,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const editorUiRef = useRef<any>(null)
|
||||
const attachedRef = useRef(false)
|
||||
const configCssRef = useRef(configCss)
|
||||
configCssRef.current = configCss
|
||||
const editorCssRef = useRef(editorCss)
|
||||
editorCssRef.current = editorCss
|
||||
// Handlers change on every render; the message listener reads refs
|
||||
const handlersRef = useRef({
|
||||
onLoad,
|
||||
onAutoSave,
|
||||
onExport,
|
||||
onEditorAccess,
|
||||
})
|
||||
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
|
||||
|
||||
// draw.io's origin: messages go only there, and only its messages
|
||||
// count (the frame could be navigated to another site)
|
||||
// (read on use: there is no window while the page renders on the server)
|
||||
const drawioOrigin = () => new URL(src, window.location.href).origin
|
||||
|
||||
const send = (action: string, data: Record<string, unknown> = {}) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
JSON.stringify({ action, ...data }),
|
||||
drawioOrigin(),
|
||||
)
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
load: ({ xml }) => send("load", { xml, autosave: 1 }),
|
||||
exportDiagram: (data) => send("export", data),
|
||||
}))
|
||||
|
||||
// Same-origin only: keep the theme CSS current without a reload
|
||||
const applyThemeCss = () => {
|
||||
if (!attachedRef.current) return
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument
|
||||
if (!doc?.head) return
|
||||
let style = doc.getElementById(STYLE_ID)
|
||||
if (!style) {
|
||||
style = doc.createElement("style")
|
||||
style.id = STYLE_ID
|
||||
doc.head.appendChild(style)
|
||||
}
|
||||
style.textContent = editorCssRef.current
|
||||
} catch {
|
||||
// Cross-origin: the configure message already applied it
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeCss()
|
||||
}, [editorCss])
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
editorUiRef.current = null
|
||||
attachedRef.current = false
|
||||
let reported: boolean | null = null
|
||||
const report = (value: boolean) => {
|
||||
if (reported === value) return
|
||||
reported = value
|
||||
handlersRef.current.onEditorAccess?.(value)
|
||||
}
|
||||
|
||||
// Watch for draw.io's scripts so the editor object can be caught
|
||||
// as it starts. Stops once hooked, or when the frame turns out to
|
||||
// be cross-origin.
|
||||
const startedAt = Date.now()
|
||||
const poll = setInterval(() => {
|
||||
const frameWindow = iframe.contentWindow as any
|
||||
if (!frameWindow) return
|
||||
let ready = false
|
||||
try {
|
||||
ready =
|
||||
frameWindow.location.href !== "about:blank" &&
|
||||
!!frameWindow.EditorUi
|
||||
} catch {
|
||||
clearInterval(poll)
|
||||
report(false)
|
||||
return
|
||||
}
|
||||
if (ready) {
|
||||
clearInterval(poll)
|
||||
const ok = watchForEditorUi(frameWindow, (ui) => {
|
||||
editorUiRef.current = ui
|
||||
})
|
||||
if (!ok) report(false)
|
||||
} else if (Date.now() - startedAt > 60_000) {
|
||||
clearInterval(poll)
|
||||
}
|
||||
}, 5)
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframe.contentWindow) return
|
||||
if (event.origin !== drawioOrigin()) return
|
||||
if (typeof event.data !== "string") return
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(event.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const handlers = handlersRef.current
|
||||
switch (data.event) {
|
||||
case "init":
|
||||
send("load", {
|
||||
xml: EMPTY_DRAWIO_DOCUMENT,
|
||||
autosave: 1,
|
||||
})
|
||||
break
|
||||
case "configure":
|
||||
send("configure", {
|
||||
config: {
|
||||
...DRAWIO_CONFIG,
|
||||
css: configCssRef.current,
|
||||
},
|
||||
})
|
||||
break
|
||||
case "load": {
|
||||
const tryAttach = () => {
|
||||
const ui = editorUiRef.current
|
||||
if (!ui || !iframe.contentWindow) return false
|
||||
attachEditor(ui, iframe.contentWindow, iframe)
|
||||
attachedRef.current = true
|
||||
applyThemeCss()
|
||||
report(true)
|
||||
return true
|
||||
}
|
||||
// The editor object is usually caught before the
|
||||
// first load; give it a moment, then fall back to
|
||||
// draw.io's own toolbar
|
||||
if (!tryAttach()) {
|
||||
setTimeout(() => {
|
||||
if (!tryAttach()) report(false)
|
||||
}, 800)
|
||||
}
|
||||
handlers.onLoad?.()
|
||||
break
|
||||
}
|
||||
case "autosave":
|
||||
handlers.onAutoSave?.(data)
|
||||
break
|
||||
case "export":
|
||||
handlers.onExport?.(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
clearInterval(poll)
|
||||
window.removeEventListener("message", handleMessage)
|
||||
detachEditor()
|
||||
}
|
||||
}, [src])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title="draw.io"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
className="block h-full w-full border-0"
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ChevronDown, Plus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
addPage,
|
||||
canManagePages,
|
||||
duplicatePage,
|
||||
removePage,
|
||||
renamePage,
|
||||
selectPage,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
|
||||
/** Page switcher at the bottom of the canvas (replaces draw.io's page tabs) */
|
||||
export function PageTabs() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.workspace
|
||||
const pages = useCanvasStore((s) => s.pages)
|
||||
const currentPageId = useCanvasStore((s) => s.currentPageId)
|
||||
const [menuFor, setMenuFor] = useState<string | null>(null)
|
||||
|
||||
if (!canManagePages() || pages.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex max-w-[min(640px,100%)] items-center gap-0.5 overflow-x-auto rounded-xl bg-card p-1 shadow-float"
|
||||
data-testid="page-tabs"
|
||||
>
|
||||
{pages.map((page) => {
|
||||
const isCurrent = page.id === currentPageId
|
||||
if (!isCurrent) {
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
type="button"
|
||||
onClick={() => selectPage(page.id)}
|
||||
className="h-7 max-w-40 shrink-0 truncate rounded-lg px-2.5 text-[13px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{page.name}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
key={page.id}
|
||||
open={menuFor === page.id}
|
||||
onOpenChange={(open) =>
|
||||
setMenuFor(open ? page.id : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-7 max-w-44 shrink-0 items-center gap-1 rounded-lg bg-accent px-2.5 text-[13px] font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{page.name}</span>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-44 p-1"
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: t.renamePage,
|
||||
run: () => renamePage(page.id),
|
||||
},
|
||||
{
|
||||
label: t.duplicatePage,
|
||||
run: () => duplicatePage(page.id),
|
||||
},
|
||||
...(pages.length > 1
|
||||
? [
|
||||
{
|
||||
label: t.deletePage,
|
||||
run: () => removePage(page.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuFor(null)
|
||||
item.run()
|
||||
}}
|
||||
className="w-full rounded-md px-2.5 py-1.5 text-left text-[13px] hover:bg-accent"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})}
|
||||
<IconButton
|
||||
label={t.addPage}
|
||||
size="sm"
|
||||
tooltipSide="top"
|
||||
onClick={addPage}
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { modKey } from "@/lib/platform"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
const BUTTON_HEIGHT = 32
|
||||
const GAP = 10
|
||||
|
||||
/**
|
||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
||||
* focus to the chat input, where the selection already shows as a chip.
|
||||
*/
|
||||
export function SelectionAsk({
|
||||
width,
|
||||
height,
|
||||
hidden,
|
||||
}: {
|
||||
/** Size of the canvas area, for keeping the button inside it */
|
||||
width: number
|
||||
height: number
|
||||
hidden: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const rect = useCanvasStore((s) => s.selectionRect)
|
||||
const selection = useCanvasStore((s) => s.selection)
|
||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || !rect || selection.length === 0 || isFreehand) return null
|
||||
// Selection scrolled out of view
|
||||
if (
|
||||
rect.x + rect.width < 0 ||
|
||||
rect.y + rect.height < 0 ||
|
||||
rect.x > width ||
|
||||
rect.y > height
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Above the selection's top-right corner, kept inside the canvas
|
||||
const approxWidth = 168
|
||||
const left = Math.min(
|
||||
Math.max(8, rect.x + rect.width - approxWidth / 2),
|
||||
width - approxWidth - 8,
|
||||
)
|
||||
const above = rect.y - BUTTON_HEIGHT - GAP
|
||||
const top =
|
||||
above > 64
|
||||
? above
|
||||
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={focusComposer}
|
||||
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
|
||||
style={{ left, top }}
|
||||
data-testid="selection-ask"
|
||||
>
|
||||
<Sparkles className="size-4 text-marker-ink" />
|
||||
{dict.workspace.askAi}
|
||||
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
|
||||
{modKey}/
|
||||
</kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Cloud,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Palette,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
|
||||
interface ExampleCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function ExampleCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
isNew,
|
||||
}: ExampleCardProps) {
|
||||
const dict = useDictionary()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
|
||||
isNew
|
||||
? "border-primary/40 ring-1 ring-primary/20"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
|
||||
isNew
|
||||
? "bg-primary/20 group-hover:bg-primary/25"
|
||||
: "bg-primary/10 group-hover:bg-primary/15"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{isNew && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
|
||||
{dict.common.new}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExamplePanel({
|
||||
setInput,
|
||||
setFiles,
|
||||
minimal = false,
|
||||
}: {
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
|
||||
const handleReplicateFlowchart = async () => {
|
||||
setInput("Replicate this flowchart.")
|
||||
|
||||
try {
|
||||
const response = await fetch(getAssetUrl("/example.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "example.png", { type: "image/png" })
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReplicateArchitecture = async () => {
|
||||
setInput("Replicate this in aws style")
|
||||
|
||||
try {
|
||||
const response = await fetch(getAssetUrl("/architecture.png"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "architecture.png", {
|
||||
type: "image/png",
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePdfExample = async () => {
|
||||
setInput("Summarize this paper as a diagram")
|
||||
|
||||
try {
|
||||
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], "chain-of-thought.txt", {
|
||||
type: "text/plain",
|
||||
})
|
||||
setFiles([file])
|
||||
} catch (error) {
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||
{!minimal && (
|
||||
<>
|
||||
{/* MCP Server Notice */}
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Terminal className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Welcome section */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.examples.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.examples.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Examples grid */}
|
||||
<div className="space-y-3">
|
||||
{!minimal && (
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||
{dict.examples.quickExamples}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<ExampleCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.paperToDiagram}
|
||||
description={dict.examples.paperDescription}
|
||||
onClick={handlePdfExample}
|
||||
isNew
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Zap className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.animatedDiagram}
|
||||
description={dict.examples.animatedDescription}
|
||||
onClick={() => {
|
||||
setInput(
|
||||
"Give me a **animated connector** diagram of transformer's architecture",
|
||||
)
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Cloud className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.awsArchitecture}
|
||||
description={dict.examples.awsDescription}
|
||||
onClick={handleReplicateArchitecture}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<GitBranch className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.replicateFlowchart}
|
||||
description={dict.examples.replicateDescription}
|
||||
onClick={handleReplicateFlowchart}
|
||||
/>
|
||||
|
||||
<ExampleCard
|
||||
icon={<Palette className="w-4 h-4 text-primary" />}
|
||||
title={dict.examples.creativeDrawing}
|
||||
description={dict.examples.creativeDescription}
|
||||
onClick={() => {
|
||||
setInput("Draw a cat for me")
|
||||
setFiles([])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
|
||||
{dict.examples.cachedNote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Download,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Link,
|
||||
Send,
|
||||
Square,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { HistoryDialog } from "@/components/history-dialog"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||
import { isRealDiagram } from "@/lib/utils"
|
||||
import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${mb.toFixed(2)}MB`
|
||||
}
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
validFiles: File[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
function validateFiles(
|
||||
newFiles: File[],
|
||||
existingCount: number,
|
||||
dict: any,
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
|
||||
const availableSlots = MAX_FILES - existingCount
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (validFiles.length >= availableSlots) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.onlyMoreAllowed, {
|
||||
slots: availableSlots,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!isValidFileType(file)) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.unsupportedType, { name: file.name }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
|
||||
const isExtractedFile = isPdfFile(file) || isTextFile(file)
|
||||
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
|
||||
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
|
||||
errors.push(
|
||||
formatMessage(dict.errors.fileExceeds, {
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
max: maxSizeMB,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[], dict: any) {
|
||||
if (errors.length === 0) return
|
||||
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
)
|
||||
} else {
|
||||
showErrorToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{formatMessage(dict.errors.filesRejected, {
|
||||
count: errors.length,
|
||||
})}
|
||||
</span>
|
||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
||||
{errors.slice(0, 3).map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li>
|
||||
{formatMessage(dict.errors.andMore, {
|
||||
count: errors.length - 3,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string
|
||||
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<
|
||||
File,
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<string, UrlData>
|
||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
// Model selector props
|
||||
models?: FlattenedModel[]
|
||||
selectedModelId?: string
|
||||
onModelSelect?: (modelId: string | undefined) => void
|
||||
onConfigureModels?: () => void
|
||||
showUnvalidatedModels?: boolean
|
||||
// Focus control props
|
||||
shouldFocus?: boolean
|
||||
onFocused?: () => void
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
function ChatInput(
|
||||
{
|
||||
input,
|
||||
status,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onStop,
|
||||
files = [],
|
||||
onFileChange = () => {},
|
||||
pdfData = new Map(),
|
||||
urlData,
|
||||
onUrlChange,
|
||||
sessionId,
|
||||
error = null,
|
||||
models = [],
|
||||
selectedModelId,
|
||||
onModelSelect = () => {},
|
||||
onConfigureModels,
|
||||
showUnvalidatedModels = false,
|
||||
shouldFocus = false,
|
||||
onFocused,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const dict = useDictionary()
|
||||
const {
|
||||
chartXML,
|
||||
diagramHistory,
|
||||
saveDiagramToFile,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
} = useDiagram()
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Expose focus method via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textareaRef.current?.focus()
|
||||
},
|
||||
}))
|
||||
|
||||
// Focus the textarea when shouldFocus becomes true
|
||||
// Use setTimeout to ensure focus happens after drawio iframe settles
|
||||
useEffect(() => {
|
||||
if (shouldFocus) {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus()
|
||||
onFocused?.()
|
||||
}, 150)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [shouldFocus, onFocused])
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
}, [])
|
||||
// Handle programmatic input changes (e.g., setInput("") after form submission)
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight()
|
||||
}, [input, adjustTextareaHeight])
|
||||
|
||||
// Load send shortcut preference from localStorage and listen for changes
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
|
||||
if (stored) setSendShortcut(stored)
|
||||
|
||||
const handleChange = (e: CustomEvent<string>) =>
|
||||
setSendShortcut(e.detail)
|
||||
window.addEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"sendShortcutChange",
|
||||
handleChange as EventListener,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e)
|
||||
adjustTextareaHeight()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey
|
||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
|
||||
const items = e.clipboardData.items
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = (
|
||||
await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
}),
|
||||
)
|
||||
).filter((f): f is File => f !== null)
|
||||
|
||||
const { validFiles, errors } = validateFiles(
|
||||
imageFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || [])
|
||||
const { validFiles, errors } = validateFiles(
|
||||
newFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (fileToRemove: File) => {
|
||||
onFileChange(files.filter((file) => file !== fileToRemove))
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (isDisabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||
isValidFileType(file),
|
||||
)
|
||||
|
||||
const { validFiles, errors } = validateFiles(
|
||||
supportedFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles])
|
||||
}
|
||||
}
|
||||
|
||||
const handleUrlExtract = async (url: string) => {
|
||||
if (!onUrlChange) return
|
||||
|
||||
setIsExtractingUrl(true)
|
||||
|
||||
try {
|
||||
const existing = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
existing.set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
onUrlChange(existing)
|
||||
|
||||
const data = await extractUrlContent(url)
|
||||
|
||||
const newUrlData = new Map(existing)
|
||||
newUrlData.set(url, data)
|
||||
onUrlChange(newUrlData)
|
||||
|
||||
setShowUrlDialog(false)
|
||||
} catch (error) {
|
||||
// Remove the URL from the data map on error
|
||||
const newUrlData = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
newUrlData.delete(url)
|
||||
onUrlChange(newUrlData)
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to extract URL content"}
|
||||
</span>,
|
||||
)
|
||||
} finally {
|
||||
setIsExtractingUrl(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="chat-form"
|
||||
onSubmit={onSubmit}
|
||||
className={`w-full transition-all duration-200 ${
|
||||
isDragging
|
||||
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* File & URL previews */}
|
||||
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
pdfData={pdfData}
|
||||
urlData={urlData}
|
||||
onRemoveUrl={
|
||||
onUrlChange
|
||||
? (url) => {
|
||||
const next = new Map(urlData)
|
||||
next.delete(url)
|
||||
onUrlChange(next)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={dict.chat.placeholder}
|
||||
disabled={isDisabled}
|
||||
aria-label="Chat input"
|
||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowHistory(true)}
|
||||
disabled={
|
||||
isDisabled || diagramHistory.length === 0
|
||||
}
|
||||
tooltipContent={dict.chat.diagramHistory}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
disabled={
|
||||
isDisabled || !isRealDiagram(chartXML)
|
||||
}
|
||||
tooltipContent={dict.chat.saveDiagram}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.uploadFile}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
{onUrlChange && (
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUrlDialog(true)}
|
||||
disabled={isDisabled}
|
||||
tooltipContent={dict.chat.ExtractURL}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
|
||||
<ButtonWithTooltip
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
disabled={isDisabled || !input.trim()}
|
||||
tooltipContent={dict.templates.saveAsTemplate}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onSelect={onModelSelect}
|
||||
onConfigure={onConfigureModels}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={showUnvalidatedModels}
|
||||
/>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
{(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
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
/>
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(
|
||||
filename,
|
||||
format,
|
||||
sessionId,
|
||||
dict.save.savedSuccessfully,
|
||||
)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
{onUrlChange && (
|
||||
<UrlInputDialog
|
||||
open={showUrlDialog}
|
||||
onOpenChange={setShowUrlDialog}
|
||||
onSubmit={handleUrlExtract}
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
)}
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,358 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import ExamplePanel from "@/components/chat-example-panel"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { Template } from "@/lib/template-storage"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: number
|
||||
thumbnailDataUrl?: string
|
||||
}
|
||||
|
||||
interface ChatLobbyProps {
|
||||
sessions: SessionMetadata[]
|
||||
onSelectSession: (id: string) => void
|
||||
onDeleteSession?: (id: string) => void
|
||||
setInput: (input: string) => void
|
||||
setFiles: (files: File[]) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
dict: {
|
||||
sessionHistory?: {
|
||||
recentChats?: string
|
||||
searchPlaceholder?: string
|
||||
noResults?: string
|
||||
justNow?: string
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
templates?: {
|
||||
title?: string
|
||||
myTemplates?: string
|
||||
}
|
||||
examples?: {
|
||||
quickExamples?: string
|
||||
}
|
||||
common: {
|
||||
delete: string
|
||||
cancel: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to format session date
|
||||
function formatSessionDate(
|
||||
timestamp: number,
|
||||
dict?: { justNow?: string },
|
||||
): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
|
||||
if (diffMins < 1) return dict?.justNow || "Just now"
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function getPanelVisibility() {
|
||||
if (typeof window === "undefined")
|
||||
return { recentChats: true, myTemplates: true, quickExamples: true }
|
||||
return {
|
||||
recentChats:
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
myTemplates:
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
quickExamples:
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatLobby({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
setInput,
|
||||
setFiles,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
dict,
|
||||
}: ChatLobbyProps) {
|
||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// Listen for panel visibility changes from settings
|
||||
useEffect(() => {
|
||||
const handler = () => setPanelVisibility(getPanelVisibility())
|
||||
window.addEventListener("panelVisibilityChange", handler)
|
||||
return () =>
|
||||
window.removeEventListener("panelVisibilityChange", handler)
|
||||
}, [])
|
||||
|
||||
const hasHistory = sessions.length > 0
|
||||
|
||||
if (!hasHistory) {
|
||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{panelVisibility.myTemplates && (
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
)}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show history + collapsible examples when there are sessions
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
{/* Recent Chats Section */}
|
||||
{panelVisibility.recentChats && (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||
</p>
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
dict.sessionHistory?.searchPlaceholder ||
|
||||
"Search chats..."
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions
|
||||
.filter((session) =>
|
||||
session.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map((session) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSelectSession(session.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.thumbnailDataUrl ? (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||
<Image
|
||||
src={session.thumbnailDataUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSessionDate(
|
||||
session.updatedAt,
|
||||
dict.sessionHistory,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onDeleteSession && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSessionToDelete(session.id)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||
title={dict.common.delete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.filter((s) =>
|
||||
s.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
).length === 0 &&
|
||||
searchQuery && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{dict.sessionHistory?.noResults ||
|
||||
"No chats found"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible My Templates Section */}
|
||||
{panelVisibility.myTemplates && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.templates?.myTemplates || "My Templates"}
|
||||
</span>
|
||||
{templatesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{templatesExpanded && (
|
||||
<div className="mt-2">
|
||||
<TemplatePanel
|
||||
setInput={setInput}
|
||||
onSendTemplate={onSendTemplate}
|
||||
currentInput={currentInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Quick Examples Section */}
|
||||
{panelVisibility.quickExamples && (
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>
|
||||
{dict.examples?.quickExamples || "Quick Examples"}
|
||||
</span>
|
||||
{examplesExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{examplesExpanded && (
|
||||
<div className="mt-2">
|
||||
<ExamplePanel
|
||||
setInput={setInput}
|
||||
setFiles={setFiles}
|
||||
minimal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{dict.sessionHistory?.deleteTitle ||
|
||||
"Delete this chat?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.sessionHistory?.deleteDescription ||
|
||||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (sessionToDelete && onDeleteSession) {
|
||||
onDeleteSession(sessionToDelete)
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
setSessionToDelete(null)
|
||||
}}
|
||||
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export function TemplateCreateDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
|
||||
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form when template changes
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [template])
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
@@ -59,6 +59,9 @@ export function TemplateEditDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
updateTemplate,
|
||||
validateImportData,
|
||||
} from "@/lib/template-storage"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||
|
||||
@@ -43,6 +44,8 @@ interface TemplatePanelProps {
|
||||
setInput: (input: string) => void
|
||||
onSendTemplate?: (template: Template) => void
|
||||
currentInput?: string
|
||||
/** Skip the title block (the surrounding dialog already has one) */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||
@@ -77,8 +80,10 @@ export function TemplatePanel({
|
||||
setInput,
|
||||
onSendTemplate,
|
||||
currentInput = "",
|
||||
hideHeader = false,
|
||||
}: TemplatePanelProps) {
|
||||
const dict = useDictionary()
|
||||
const templatesVersion = useUiStore((s) => s.templatesVersion)
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
@@ -108,9 +113,10 @@ export function TemplatePanel({
|
||||
? searchTemplates(templates, searchQuery)
|
||||
: templates
|
||||
|
||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
}, [loadTemplates])
|
||||
}, [loadTemplates, templatesVersion])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
loadTemplates()
|
||||
@@ -220,7 +226,8 @@ export function TemplatePanel({
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||
// Local date as YYYY-MM-DD (toISOString would give UTC)
|
||||
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -302,18 +309,42 @@ export function TemplatePanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the empty state and the list, so import works in both
|
||||
const importInput = (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
)
|
||||
const importMessageBox = importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Empty state: no templates at all
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
<div className="py-6 px-2 animate-fade-in">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.templates.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.templates.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{dict.templates.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
{dict.templates.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<FileText className="w-8 h-8 text-primary/60" />
|
||||
@@ -332,6 +363,18 @@ export function TemplatePanel({
|
||||
<Plus className="w-4 h-4" />
|
||||
{dict.templates.createFirst}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
{importInput}
|
||||
{importMessageBox && (
|
||||
<div className="mt-3">{importMessageBox}</div>
|
||||
)}
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
@@ -389,27 +432,11 @@ export function TemplatePanel({
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
{importInput}
|
||||
</div>
|
||||
|
||||
{/* Import message */}
|
||||
{importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)}
|
||||
{importMessageBox}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading
|
||||
@@ -447,6 +474,8 @@ export function TemplatePanel({
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the action buttons
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
expandedTools: Record<string, boolean>
|
||||
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||
copiedToolCallId: string | null
|
||||
copyFailedToolCallId: string | null
|
||||
dict: {
|
||||
tools: { complete: string }
|
||||
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||
}
|
||||
}
|
||||
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
part,
|
||||
expandedTools,
|
||||
setExpandedTools,
|
||||
onCopy,
|
||||
copiedToolCallId,
|
||||
copyFailedToolCallId,
|
||||
dict,
|
||||
}: ToolCallCardProps) {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
// Default to expanded for all states (user can manually collapse if needed)
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const isCopied = copiedToolCallId === callId
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setExpandedTools((prev) => ({
|
||||
...prev,
|
||||
[callId]: !isExpanded,
|
||||
}))
|
||||
}
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
if (input.xml) {
|
||||
textToCopy = input.xml
|
||||
} else if (input.operations && Array.isArray(input.operations)) {
|
||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
||||
} else if (Object.keys(input).length > 0) {
|
||||
textToCopy = JSON.stringify(input, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{getToolDisplayName(toolName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "input-streaming" && (
|
||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{state === "output-available" && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||
{dict.tools.complete}
|
||||
</span>
|
||||
{isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={
|
||||
copiedToolCallId === callId
|
||||
? dict.chat.copied
|
||||
: copyFailedToolCallId === callId
|
||||
? dict.chat.failedToCopy
|
||||
: dict.chat.copyResponse
|
||||
}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{input && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||
{typeof input === "object" && input.xml ? (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available" ? (
|
||||
<pre
|
||||
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-mono), ui-monospace, monospace",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{input.xml}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
code={JSON.stringify(input, null, 2)}
|
||||
language="json"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{output &&
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||
: output}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { MAX_FILES, MAX_FILE_SIZE as MAX_IMAGE_SIZE } from "@/lib/chat-helpers"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
|
||||
// Image formats every supported model provider accepts (SVG is read as text)
|
||||
const SUPPORTED_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]
|
||||
|
||||
/** Accept list for the attachment picker */
|
||||
export const ATTACHMENT_ACCEPT =
|
||||
"image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return (
|
||||
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||
isPdfFile(file) ||
|
||||
isTextFile(file)
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024
|
||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${mb.toFixed(2)}MB`
|
||||
}
|
||||
|
||||
/** Keep the files that can be attached; describe the rest as errors */
|
||||
export function validateFiles(
|
||||
newFiles: File[],
|
||||
existingCount: number,
|
||||
dict: any,
|
||||
): { validFiles: File[]; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
const validFiles: File[] = []
|
||||
const availableSlots = MAX_FILES - existingCount
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
|
||||
return { validFiles, errors }
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (validFiles.length >= availableSlots) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.onlyMoreAllowed, {
|
||||
slots: availableSlots,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!isValidFileType(file)) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.unsupportedType, { name: file.name }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
// PDFs and text files are extracted in the browser, so only images
|
||||
// have a size limit
|
||||
const isExtractedFile = isPdfFile(file) || isTextFile(file)
|
||||
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
|
||||
errors.push(
|
||||
formatMessage(dict.errors.fileExceeds, {
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
max: MAX_IMAGE_SIZE / 1024 / 1024,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
return { validFiles, errors }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
MessageSquareDashed,
|
||||
PanelRightClose,
|
||||
Settings2,
|
||||
SquarePen,
|
||||
} from "lucide-react"
|
||||
import { useRef } from "react"
|
||||
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { modKey } from "@/lib/platform"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import {
|
||||
PANEL_MAX_WIDTH,
|
||||
PANEL_MIN_WIDTH,
|
||||
useSettingsStore,
|
||||
} from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { MessageList } from "./message-list"
|
||||
import { SessionMenu, useSessionTitle } from "./session-menu"
|
||||
import { VersionStrip } from "./version-strip"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
/** Drag handle on the panel's left edge (the panel sits on the right) */
|
||||
function ResizeHandle() {
|
||||
const panelWidth = useSettingsStore((s) => s.panelWidth)
|
||||
const setPanelWidth = useSettingsStore((s) => s.setPanelWidth)
|
||||
const startRef = useRef<{ x: number; width: number } | null>(null)
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: a draggable handle, not a divider line
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={panelWidth}
|
||||
aria-valuemin={PANEL_MIN_WIDTH}
|
||||
aria-valuemax={PANEL_MAX_WIDTH}
|
||||
tabIndex={-1}
|
||||
className="absolute top-4 -left-1.5 bottom-4 z-10 w-3 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:bg-foreground/0 after:transition-colors hover:after:bg-foreground/15"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
const panel = e.currentTarget.parentElement
|
||||
startRef.current = {
|
||||
x: e.clientX,
|
||||
width: panel?.getBoundingClientRect().width ?? 384,
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const start = startRef.current
|
||||
if (!start) return
|
||||
// Dragging left widens the panel
|
||||
setPanelWidth(start.width - (e.clientX - start.x))
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
startRef.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyThread() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center">
|
||||
<MessageSquareDashed className="size-5 text-faint" />
|
||||
<p className="max-w-60 text-[13px] text-muted-foreground">
|
||||
{dict.chat.emptyThread}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The floating chat panel: header, versions, conversation, message box */
|
||||
export function ChatPanel({
|
||||
mobile = false,
|
||||
onOpenFile,
|
||||
}: {
|
||||
mobile?: boolean
|
||||
onOpenFile: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const engine = useChatEngine()
|
||||
const { loadDiagram, isDrawioReady } = useDiagram()
|
||||
// An external draw.io has no app toolbar (and so no Export button there)
|
||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
||||
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
|
||||
const title = useSessionTitle()
|
||||
const panelWidth = useSettingsStore((s) => s.panelWidth)
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col bg-card",
|
||||
mobile
|
||||
? "absolute inset-0"
|
||||
: "absolute top-3 right-3 bottom-3 z-20 rounded-2xl shadow-float animate-panel-in",
|
||||
)}
|
||||
style={mobile ? undefined : { width: panelWidth }}
|
||||
aria-label={dict.nav.aiChat}
|
||||
data-testid="chat-panel"
|
||||
>
|
||||
<header className="flex h-[52px] shrink-0 items-center gap-1 pr-2 pl-3">
|
||||
<BrandMark />
|
||||
<SessionMenu onOpenFile={onOpenFile}>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 hover:bg-accent"
|
||||
data-testid="session-title"
|
||||
>
|
||||
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-faint" />
|
||||
</button>
|
||||
</SessionMenu>
|
||||
<div className="ml-auto flex items-center">
|
||||
{!hasEditor && isDrawioReady && (
|
||||
<IconButton
|
||||
label={dict.workspace.export}
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
data-testid="panel-export"
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={dict.nav.newChat}
|
||||
onClick={engine.newChat}
|
||||
disabled={engine.isBusy}
|
||||
data-testid="new-chat-button"
|
||||
>
|
||||
<SquarePen />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={dict.nav.settings}
|
||||
onClick={() => openSettings()}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings2 />
|
||||
</IconButton>
|
||||
{!mobile && (
|
||||
<IconButton
|
||||
label={dict.nav.hidePanel}
|
||||
shortcut={`${modKey}B`}
|
||||
onClick={() => setPanelOpen(false)}
|
||||
>
|
||||
<PanelRightClose />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<VersionStrip />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
||||
</div>
|
||||
|
||||
{DEBUG && (
|
||||
<DevXmlSimulator
|
||||
setMessages={engine.setMessages as any}
|
||||
onDisplayChart={(xml) => loadDiagram(xml, true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 p-2.5 pt-1">
|
||||
<Composer
|
||||
placeholder={
|
||||
engine.messages.length > 0 &&
|
||||
engine.chatSelection.length === 0
|
||||
? dict.chat.placeholderFollowUp
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!mobile && <ResizeHandle />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { describeChanges } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
function Sheet({
|
||||
label,
|
||||
svg,
|
||||
loadingText,
|
||||
emptyText,
|
||||
}: {
|
||||
label: string
|
||||
svg: string | null | undefined
|
||||
loadingText: string
|
||||
emptyText: string
|
||||
}) {
|
||||
return (
|
||||
<figure className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<figcaption className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</figcaption>
|
||||
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
|
||||
{svg === "" ? (
|
||||
<span className="text-xs text-faint">{emptyText}</span>
|
||||
) : svg ? (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{loadingText}
|
||||
</Shimmer>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
/** A saved version next to the current canvas, with restore */
|
||||
export function CompareDialog() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const { getVersionSvg, chartXML } = useDiagram()
|
||||
const versionId = useUiStore((s) => s.compareVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
||||
|
||||
const index = versions.findIndex((v) => v.id === versionId)
|
||||
const version = index >= 0 ? versions[index] : null
|
||||
|
||||
// Whether the canvas is this version, on every page
|
||||
const same = useMemo(
|
||||
() => !version || isSameDocument(version.xml, chartXML),
|
||||
[version, chartXML],
|
||||
)
|
||||
// What the canvas has changed since this version on the first page
|
||||
// (null: nothing there)
|
||||
const changes = useMemo(() => {
|
||||
if (!version || same) return null
|
||||
const { summary } = diffDiagrams(version.xml, chartXML)
|
||||
return Object.values(summary).some((n) => n > 0) ? summary : null
|
||||
}, [version, chartXML, same])
|
||||
|
||||
// Picture of the canvas as it is now
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
let cancelled = false
|
||||
setCurrentSvg(null)
|
||||
getVersionSvg().then((svg) => {
|
||||
if (!cancelled) setCurrentSvg(svg)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [versionId])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!version}
|
||||
onOpenChange={(open) => !open && closeCompare()}
|
||||
>
|
||||
<DialogContent className="gap-5 sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.compareTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!version
|
||||
? ""
|
||||
: same
|
||||
? t.isOnCanvas
|
||||
: changes
|
||||
? `${formatMessage(t.compareSince, { n: index + 1 })} ${describeChanges(changes, t)}`
|
||||
: formatMessage(t.differsElsewhere, {
|
||||
n: index + 1,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{version && (
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Sheet
|
||||
label={formatMessage(t.versionLabel, {
|
||||
n: index + 1,
|
||||
})}
|
||||
svg={version.svg}
|
||||
loadingText={t.rendering}
|
||||
emptyText={t.noPreview}
|
||||
/>
|
||||
<Sheet
|
||||
label={t.currentCanvas}
|
||||
svg={currentSvg}
|
||||
loadingText={t.rendering}
|
||||
emptyText={t.noPreview}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={index <= 0}
|
||||
onClick={() => openCompare(versions[index - 1].id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
aria-label={t.previous}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={index < 0 || index >= versions.length - 1}
|
||||
onClick={() => openCompare(versions[index + 1].id)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
aria-label={t.next}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{index + 1} / {versions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!version || same || engine.isBusy}
|
||||
onClick={() => {
|
||||
if (!version) return
|
||||
engine.restoreVersion(version.id)
|
||||
closeCompare()
|
||||
}}
|
||||
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||
data-testid="compare-restore"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{formatMessage(t.restoreVersion, { n: index + 1 })}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
ArrowUp,
|
||||
BookMarked,
|
||||
BookmarkPlus,
|
||||
Globe,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Square,
|
||||
SquareDashedMousePointer,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||
import { ErrorToast } from "@/components/error-toast"
|
||||
import { FilePreviewList } from "@/components/file-preview-list"
|
||||
import { ModelSelector } from "@/components/model-selector"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { modKey } from "@/lib/platform"
|
||||
import { extractUrlContent } from "@/lib/url-utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
function showErrorToast(message: React.ReactNode) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
|
||||
),
|
||||
{ duration: 5000 },
|
||||
)
|
||||
}
|
||||
|
||||
function showValidationErrors(errors: string[], dict: any) {
|
||||
if (errors.length === 0) return
|
||||
if (errors.length === 1) {
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||
)
|
||||
return
|
||||
}
|
||||
showErrorToast(
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">
|
||||
{formatMessage(dict.errors.filesRejected, {
|
||||
count: errors.length,
|
||||
})}
|
||||
</span>
|
||||
<ul className="list-inside list-disc text-xs text-muted-foreground">
|
||||
{errors.slice(0, 3).map((err) => (
|
||||
<li key={err}>{err}</li>
|
||||
))}
|
||||
{errors.length > 3 && (
|
||||
<li>
|
||||
{formatMessage(dict.errors.andMore, {
|
||||
count: errors.length - 3,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
disabledHint,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
/** Why the item can't be used now, shown under it while disabled */
|
||||
disabledHint?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="group/item flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] hover:bg-accent disabled:pointer-events-none [&_svg]:mt-px [&_svg]:size-4 [&_svg]:text-muted-foreground"
|
||||
>
|
||||
<span className="contents group-disabled/item:[&>svg]:opacity-40">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block group-disabled/item:opacity-40">
|
||||
{children}
|
||||
</span>
|
||||
{disabled && disabledHint && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{disabledHint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The message box: text, attachments, the selected shapes, the model picker
|
||||
* and send/stop. `variant="hero"` is the large box on the start screen.
|
||||
*/
|
||||
export function Composer({
|
||||
variant = "panel",
|
||||
placeholder,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
variant?: "panel" | "hero"
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const engine = useChatEngine()
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
files,
|
||||
setFiles,
|
||||
pdfData,
|
||||
urlData,
|
||||
setUrlData,
|
||||
isBusy,
|
||||
error,
|
||||
chatSelection,
|
||||
dismissSelection,
|
||||
modelConfig,
|
||||
} = engine
|
||||
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const focusToken = useUiStore((s) => s.focusComposerToken)
|
||||
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
|
||||
|
||||
const isHero = variant === "hero"
|
||||
// Allow a retry when the last request failed, even mid-stream
|
||||
const isDisabled = isBusy && !error
|
||||
const canSend =
|
||||
!isDisabled && !engine.isExtractingAttachments && !!input.trim()
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [input, adjustHeight])
|
||||
|
||||
// Focus requests from elsewhere (new chat, "ask AI" button, ⌘/)
|
||||
useEffect(() => {
|
||||
if (focusToken === 0 && !autoFocus) return
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 60)
|
||||
return () => clearTimeout(timer)
|
||||
}, [focusToken, autoFocus])
|
||||
|
||||
const addFiles = (newFiles: File[]) => {
|
||||
const { validFiles, errors } = validateFiles(
|
||||
newFiles,
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
showValidationErrors(errors, dict)
|
||||
if (validFiles.length > 0) setFiles([...files, ...validFiles])
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter that confirms an IME candidate must not send the message
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
|
||||
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
if (canSend) engine.submit()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (isDisabled) return
|
||||
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
)
|
||||
if (imageItems.length === 0) return
|
||||
const imageFiles = imageItems
|
||||
.map((item, index) => {
|
||||
const file = item.getAsFile()
|
||||
if (!file) return null
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type },
|
||||
)
|
||||
})
|
||||
.filter((f): f is File => f !== null)
|
||||
addFiles(imageFiles)
|
||||
}
|
||||
|
||||
const handleUrlExtract = async (url: string) => {
|
||||
setIsExtractingUrl(true)
|
||||
// Functional updates, so a removal or send made while extracting is
|
||||
// not overwritten when the request finishes
|
||||
try {
|
||||
setUrlData((prev) =>
|
||||
new Map(prev).set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
}),
|
||||
)
|
||||
const data = await extractUrlContent(url)
|
||||
setUrlData((prev) =>
|
||||
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
||||
)
|
||||
setShowUrlDialog(false)
|
||||
} catch (err) {
|
||||
setUrlData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">
|
||||
{err instanceof Error ? err.message : dict.url.failed}
|
||||
</span>,
|
||||
)
|
||||
} finally {
|
||||
setIsExtractingUrl(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectionLabel =
|
||||
chatSelection.length === 1
|
||||
? formatMessage(dict.selection.one, {
|
||||
label:
|
||||
chatSelection[0].label ||
|
||||
(chatSelection[0].isEdge
|
||||
? dict.selection.connector
|
||||
: dict.selection.shape),
|
||||
})
|
||||
: formatMessage(dict.selection.many, {
|
||||
count: chatSelection.length,
|
||||
})
|
||||
|
||||
const hasChips =
|
||||
chatSelection.length > 0 || files.length > 0 || urlData.size > 0
|
||||
|
||||
return (
|
||||
<form
|
||||
id={isHero ? "hero-chat-form" : "chat-form"}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSend) engine.submit()
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (!isDisabled) addFiles(Array.from(e.dataTransfer.files))
|
||||
}}
|
||||
className={cn(
|
||||
"group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150",
|
||||
"focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]",
|
||||
isHero && "rounded-[18px] border-transparent shadow-pop",
|
||||
isDragging &&
|
||||
"border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]",
|
||||
)}
|
||||
>
|
||||
{hasChips && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-2.5 pt-2.5">
|
||||
{chatSelection.length > 0 && (
|
||||
<span
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-lg bg-marker-soft py-1 pr-1 pl-2 text-xs font-medium text-marker-ink"
|
||||
data-testid="selection-chip"
|
||||
>
|
||||
<SquareDashedMousePointer className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{selectionLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSelection}
|
||||
aria-label={dict.selection.remove}
|
||||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{(files.length > 0 || urlData.size > 0) && (
|
||||
<div className="w-full">
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={(file) =>
|
||||
setFiles(files.filter((f) => f !== file))
|
||||
}
|
||||
pdfData={pdfData}
|
||||
urlData={urlData}
|
||||
onRemoveUrl={(url) =>
|
||||
setUrlData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(chatSelection.length > 0
|
||||
? dict.selection.placeholder
|
||||
: dict.chat.placeholder)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
aria-label={dict.chat.inputLabel}
|
||||
data-testid="chat-input"
|
||||
rows={isHero ? 3 : 2}
|
||||
className={cn(
|
||||
"block w-full resize-none bg-transparent text-foreground outline-none placeholder:text-faint scrollbar-thin disabled:opacity-60",
|
||||
isHero
|
||||
? "min-h-[84px] px-[18px] pt-4 pb-1 text-[15px] leading-relaxed"
|
||||
: "min-h-[52px] px-3.5 pt-3 pb-1 text-sm",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
isHero ? "px-2.5 pt-1 pb-2.5" : "px-1.5 pt-0.5 pb-1.5",
|
||||
)}
|
||||
>
|
||||
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<IconButton
|
||||
label={dict.chat.attach}
|
||||
tooltipSide="top"
|
||||
disabled={isDisabled}
|
||||
data-testid="composer-add"
|
||||
>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-64 p-1"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<Paperclip />}
|
||||
onClick={() => {
|
||||
setAddOpen(false)
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
{dict.chat.uploadFile}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<Globe />}
|
||||
onClick={() => {
|
||||
setAddOpen(false)
|
||||
setShowUrlDialog(true)
|
||||
}}
|
||||
>
|
||||
{dict.chat.ExtractURL}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<BookmarkPlus />}
|
||||
disabled={!input.trim()}
|
||||
disabledHint={dict.templates.saveDisabledHint}
|
||||
onClick={() => {
|
||||
setAddOpen(false)
|
||||
setSaveTemplateOpen(true)
|
||||
}}
|
||||
>
|
||||
{dict.templates.saveAsTemplate}
|
||||
</MenuItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
label={dict.templates.myTemplates}
|
||||
tooltipSide="top"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setTemplatesOpen(true)}
|
||||
data-testid="composer-templates"
|
||||
>
|
||||
<BookMarked />
|
||||
</IconButton>
|
||||
|
||||
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<ModelSelector
|
||||
models={modelConfig.models}
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onSelect={modelConfig.setSelectedModelId}
|
||||
onConfigure={() => openSettings("models")}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={
|
||||
modelConfig.showUnvalidatedModels
|
||||
}
|
||||
/>
|
||||
{!isBusy && input.trim() && (
|
||||
<kbd
|
||||
className="hidden shrink-0 font-sans text-[11px] text-faint sm:inline"
|
||||
aria-hidden
|
||||
>
|
||||
{sendShortcut === "enter" ? "↵" : `${modKey}↵`}
|
||||
</kbd>
|
||||
)}
|
||||
{isBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={engine.stop}
|
||||
aria-label={dict.chat.stopGeneration}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background transition-opacity hover:opacity-85"
|
||||
>
|
||||
<Square className="size-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSend}
|
||||
aria-label={dict.chat.send}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-[opacity,background-color] hover:opacity-90 disabled:bg-muted disabled:text-faint"
|
||||
>
|
||||
<ArrowUp className="size-4 stroke-[2.25]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={ATTACHMENT_ACCEPT}
|
||||
data-testid="attachment-input"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
onChange={(e) => {
|
||||
addFiles(Array.from(e.target.files || []))
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<UrlInputDialog
|
||||
open={showUrlDialog}
|
||||
onOpenChange={setShowUrlDialog}
|
||||
onSubmit={handleUrlExtract}
|
||||
isExtracting={isExtractingUrl}
|
||||
/>
|
||||
<TemplateCreateDialog
|
||||
open={saveTemplateOpen}
|
||||
onOpenChange={setSaveTemplateOpen}
|
||||
onSuccess={() => {
|
||||
setSaveTemplateOpen(false)
|
||||
bumpTemplates()
|
||||
}}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
<Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col gap-0 p-0 sm:max-w-lg">
|
||||
<DialogHeader className="px-5 pt-5 pb-2">
|
||||
<DialogTitle>{dict.templates.myTemplates}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
||||
<TemplatePanel
|
||||
hideHeader
|
||||
setInput={(value) => {
|
||||
setInput(value)
|
||||
setTemplatesOpen(false)
|
||||
}}
|
||||
currentInput={input}
|
||||
onSendTemplate={(template) => {
|
||||
setTemplatesOpen(false)
|
||||
engine.sendTemplate(template.prompt)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { FileText, FileUp, History, Pencil } from "lucide-react"
|
||||
import type React from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { SessionMenu } from "./session-menu"
|
||||
|
||||
interface Example {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
/** Picture of the result (or of the input, for "replicate" examples) */
|
||||
image?: string
|
||||
prompt: string
|
||||
/** Bundled file sent with the prompt; matches the cached example */
|
||||
file?: { path: string; name: string; type: string }
|
||||
}
|
||||
|
||||
async function loadExampleFile(file: NonNullable<Example["file"]>) {
|
||||
const response = await fetch(getAssetUrl(file.path))
|
||||
const blob = await response.blob()
|
||||
return new File([blob], file.name, { type: file.type })
|
||||
}
|
||||
|
||||
/** One start-screen choice: a small picture, a title and one line */
|
||||
function ExampleCard({
|
||||
onClick,
|
||||
title,
|
||||
description,
|
||||
picture,
|
||||
plain,
|
||||
testId,
|
||||
}: {
|
||||
onClick: () => void
|
||||
title: string
|
||||
description: string
|
||||
picture: React.ReactNode
|
||||
/** An icon on a tinted tile instead of a diagram on paper */
|
||||
plain?: boolean
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25"
|
||||
data-testid={testId}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg",
|
||||
plain ? "bg-muted" : "sheet-light bg-white",
|
||||
)}
|
||||
>
|
||||
{picture}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screen over an empty canvas: one large message box and the cached
|
||||
* examples (they answer instantly without calling a model).
|
||||
*/
|
||||
export function LobbyHero({
|
||||
onDrawYourself,
|
||||
onOpenFile,
|
||||
compact = false,
|
||||
}: {
|
||||
onDrawYourself: () => void
|
||||
onOpenFile: () => void
|
||||
/** Phone layout: no example pictures, tighter spacing */
|
||||
compact?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.lobby
|
||||
const engine = useChatEngine()
|
||||
|
||||
// Prompts and file names must stay as they are: cached responses match them
|
||||
const examples: Example[] = [
|
||||
{
|
||||
key: "paper",
|
||||
title: dict.examples.paperToDiagram,
|
||||
description: dict.examples.paperDescription,
|
||||
prompt: "Summarize this paper as a diagram",
|
||||
file: {
|
||||
path: "/chain-of-thought.txt",
|
||||
name: "chain-of-thought.txt",
|
||||
type: "text/plain",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "animated",
|
||||
title: dict.examples.animatedDiagram,
|
||||
description: dict.examples.animatedDescription,
|
||||
image: "/animated_connectors.svg",
|
||||
prompt: "Give me a **animated connector** diagram of transformer's architecture",
|
||||
},
|
||||
{
|
||||
key: "aws",
|
||||
title: dict.examples.awsArchitecture,
|
||||
description: dict.examples.awsDescription,
|
||||
image: "/aws_demo.svg",
|
||||
prompt: "Replicate this in aws style",
|
||||
file: {
|
||||
path: "/architecture.png",
|
||||
name: "architecture.png",
|
||||
type: "image/png",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "flowchart",
|
||||
title: dict.examples.replicateFlowchart,
|
||||
description: dict.examples.replicateDescription,
|
||||
image: "/example.png",
|
||||
prompt: "Replicate this flowchart.",
|
||||
file: {
|
||||
path: "/example.png",
|
||||
name: "example.png",
|
||||
type: "image/png",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "cat",
|
||||
title: dict.examples.creativeDrawing,
|
||||
description: dict.examples.creativeDescription,
|
||||
image: "/cat_demo.svg",
|
||||
prompt: "Draw a cat for me",
|
||||
},
|
||||
]
|
||||
|
||||
const pickExample = async (example: Example) => {
|
||||
engine.setInput(example.prompt)
|
||||
if (!example.file) {
|
||||
engine.setFiles([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
engine.setFiles([await loadExampleFile(example.file)])
|
||||
} catch (error) {
|
||||
console.error(dict.errors.failedToLoadExample, error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full",
|
||||
compact ? "max-w-xl px-4 py-8" : "max-w-[720px] px-6 py-10",
|
||||
)}
|
||||
data-testid="lobby-hero"
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
"text-center font-semibold tracking-[-0.025em] text-foreground",
|
||||
compact
|
||||
? "text-[26px] leading-tight"
|
||||
: "text-[34px] leading-[1.15]",
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"mx-auto mt-2.5 max-w-[34em] text-center text-muted-foreground",
|
||||
compact ? "mb-5 text-[13px]" : "mb-6 text-[14.5px]",
|
||||
)}
|
||||
>
|
||||
{t.lede}
|
||||
</p>
|
||||
|
||||
<Composer
|
||||
variant="hero"
|
||||
autoFocus={!compact}
|
||||
placeholder={t.placeholder}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-5 grid gap-2",
|
||||
compact ? "grid-cols-1" : "grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{examples.map((example) =>
|
||||
compact ? (
|
||||
<button
|
||||
key={example.key}
|
||||
type="button"
|
||||
onClick={() => pickExample(example)}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-left hover:border-foreground/25"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium">
|
||||
{example.title}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{example.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ExampleCard
|
||||
key={example.key}
|
||||
onClick={() => pickExample(example)}
|
||||
title={example.title}
|
||||
description={example.description}
|
||||
testId={`example-${example.key}`}
|
||||
picture={
|
||||
example.image ? (
|
||||
// biome-ignore lint/performance/noImgElement: static preview
|
||||
<img
|
||||
src={getAssetUrl(example.image)}
|
||||
alt=""
|
||||
className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||
/>
|
||||
) : (
|
||||
<FileText className="size-5 text-muted-foreground" />
|
||||
)
|
||||
}
|
||||
plain={!example.image}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{!compact && (
|
||||
<ExampleCard
|
||||
onClick={onOpenFile}
|
||||
title={dict.workspace.openFile}
|
||||
description={t.openFileHint}
|
||||
testId="lobby-open-file"
|
||||
picture={
|
||||
<FileUp className="size-5 text-muted-foreground" />
|
||||
}
|
||||
plain
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs text-faint">
|
||||
{dict.examples.cachedNote}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
|
||||
{engine.sessions.length > 0 && (
|
||||
<SessionMenu align="center" onOpenFile={onOpenFile}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<History className="size-4" />
|
||||
{t.recent}
|
||||
</button>
|
||||
</SessionMenu>
|
||||
)}
|
||||
{compact && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFile}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<FileUp className="size-4" />
|
||||
{dict.workspace.openFile}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDrawYourself}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
data-testid="draw-yourself"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
{t.drawYourself}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import type { UIMessage } from "ai"
|
||||
import {
|
||||
AlertCircle,
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
FileCode,
|
||||
FileText,
|
||||
Link,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||
import type { ToolPartLike } from "@/components/chat/types"
|
||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { copyText } from "@/lib/clipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import {
|
||||
getMessageTextContent,
|
||||
getUserOriginalText,
|
||||
splitTextIntoFileSections,
|
||||
type TextSection,
|
||||
} from "./message-text"
|
||||
import { ToolActivity } from "./tool-activity"
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex size-7 items-center justify-center rounded-lg text-faint transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-3.5",
|
||||
active && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachedSection({ section }: { section: TextSection }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dict = useDictionary()
|
||||
const Icon =
|
||||
section.fileType === "pdf"
|
||||
? FileText
|
||||
: section.fileType === "url"
|
||||
? Link
|
||||
: FileCode
|
||||
const chars =
|
||||
section.charCount && section.charCount >= 1000
|
||||
? `${(section.charCount / 1000).toFixed(1)}k`
|
||||
: section.charCount
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{section.filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-faint">
|
||||
{chars} {dict.file.chars}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap border-t border-border px-2.5 py-2 text-xs text-muted-foreground scrollbar-thin">
|
||||
{section.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageText({ text, isUser }: { text: string; isUser: boolean }) {
|
||||
const sections = splitTextIntoFileSections(text)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sections.map((section, index) =>
|
||||
section.type === "text" ? (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"prose prose-sm max-w-none break-words text-[14px] leading-relaxed text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none",
|
||||
!isUser && "dark:prose-invert",
|
||||
isUser && "dark:prose-invert",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown>{section.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<AttachedSection key={index} section={section} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage({
|
||||
message,
|
||||
index,
|
||||
isLast,
|
||||
}: {
|
||||
message: UIMessage
|
||||
index: number
|
||||
isLast: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const engine = useChatEngine()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editText, setEditText] = useState("")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [saveTemplate, setSaveTemplate] = useState(false)
|
||||
const bumpTemplates = useUiStore((s) => s.bumpTemplates)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const text = getMessageTextContent(message)
|
||||
const canEdit = isLast && !engine.isBusy
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) textareaRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
const startEdit = () => {
|
||||
if (!canEdit) return
|
||||
setEditText(getUserOriginalText(message))
|
||||
setEditing(true)
|
||||
}
|
||||
const submitEdit = () => {
|
||||
if (!editText.trim()) return
|
||||
engine.editMessage(index, editText.trim())
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="ml-6 flex flex-col gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
rows={Math.min(editText.split("\n").length + 1, 6)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing(false)
|
||||
else if (
|
||||
e.key === "Enter" &&
|
||||
(e.metaKey || e.ctrlKey)
|
||||
) {
|
||||
e.preventDefault()
|
||||
submitEdit()
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none rounded-xl border border-foreground/20 bg-card px-3 py-2.5 text-sm outline-none focus:shadow-[0_0_0_4px_var(--accent)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="h-7 rounded-lg px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitEdit}
|
||||
disabled={!editText.trim()}
|
||||
className="h-7 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground disabled:opacity-40"
|
||||
>
|
||||
{dict.chat.saveAndSubmit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
|
||||
url: string
|
||||
}[]
|
||||
|
||||
return (
|
||||
<div className="group/message flex items-start justify-end gap-1">
|
||||
<div className="mt-1 flex shrink-0 items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
|
||||
{canEdit && (
|
||||
<ActionButton
|
||||
label={dict.chat.editMessage}
|
||||
onClick={startEdit}
|
||||
>
|
||||
<Pencil />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
label={copied ? dict.chat.copied : dict.chat.copyResponse}
|
||||
onClick={async () => {
|
||||
const ok = await copyText(text)
|
||||
if (!ok) toast.error(dict.chat.failedToCopyDetail)
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
label={dict.templates.saveAsTemplate}
|
||||
onClick={() => setSaveTemplate(true)}
|
||||
>
|
||||
<BookmarkPlus />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-[85%] rounded-2xl rounded-br-md bg-secondary px-3.5 py-2.5",
|
||||
canEdit &&
|
||||
"cursor-pointer transition-colors hover:bg-interactive-active",
|
||||
)}
|
||||
role={canEdit ? "button" : undefined}
|
||||
tabIndex={canEdit ? 0 : undefined}
|
||||
onClick={startEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (canEdit && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault()
|
||||
startEdit()
|
||||
}
|
||||
}}
|
||||
title={canEdit ? dict.chat.clickToEdit : undefined}
|
||||
>
|
||||
{images.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{images.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={image.url}
|
||||
width={96}
|
||||
height={72}
|
||||
alt={dict.chat.attachedImage}
|
||||
className="h-[72px] w-24 rounded-lg border border-border bg-white object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{text && <MessageText text={text} isUser />}
|
||||
</div>
|
||||
{saveTemplate && (
|
||||
<TemplateCreateDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||
onSuccess={() => {
|
||||
setSaveTemplate(false)
|
||||
bumpTemplates()
|
||||
}}
|
||||
initialPrompt={getUserOriginalText(message)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
index,
|
||||
isLastMessage,
|
||||
isLastAssistant,
|
||||
}: {
|
||||
message: UIMessage
|
||||
index: number
|
||||
isLastMessage: boolean
|
||||
isLastAssistant: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
// The thinking header in the page language
|
||||
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
|
||||
}
|
||||
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
|
||||
return (
|
||||
<p>
|
||||
{duration === 1
|
||||
? dict.reasoning.thoughtForOne
|
||||
: dict.reasoning.thoughtFor.replace(
|
||||
"{duration}",
|
||||
String(duration),
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const engine = useChatEngine()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
|
||||
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
|
||||
const parts = message.parts ?? []
|
||||
const isCachedExample = parts.some((p: any) =>
|
||||
p.toolCallId?.startsWith("cached-"),
|
||||
)
|
||||
const text = getMessageTextContent(message)
|
||||
|
||||
const submitFeedback = async (value: "good" | "bad") => {
|
||||
if (feedback === value) {
|
||||
setFeedback(null)
|
||||
return
|
||||
}
|
||||
setFeedback(value)
|
||||
try {
|
||||
await fetch(getApiEndpoint("/api/log-feedback"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messageId: message.id,
|
||||
feedback: value,
|
||||
sessionId: engine.langfuseSessionId,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to log feedback:", error)
|
||||
toast.error(dict.errors.failedToRecordFeedback)
|
||||
setFeedback(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Consecutive text parts render together; tools render in place
|
||||
const blocks: { kind: "text" | "tool" | "reasoning"; index: number }[] = []
|
||||
parts.forEach((part, partIndex) => {
|
||||
if (part.type === "reasoning")
|
||||
blocks.push({ kind: "reasoning", index: partIndex })
|
||||
else if (part.type?.startsWith("tool-"))
|
||||
blocks.push({ kind: "tool", index: partIndex })
|
||||
else if (part.type === "text") {
|
||||
const last = blocks[blocks.length - 1]
|
||||
if (last?.kind !== "text")
|
||||
blocks.push({ kind: "text", index: partIndex })
|
||||
}
|
||||
})
|
||||
|
||||
const textRun = (start: number) => {
|
||||
const out: string[] = []
|
||||
for (let i = start; i < parts.length && parts[i].type === "text"; i++) {
|
||||
out.push((parts[i] as { text: string }).text)
|
||||
}
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/message space-y-2.5">
|
||||
{blocks.map((block) => {
|
||||
const part = parts[block.index]
|
||||
if (block.kind === "reasoning") {
|
||||
const isStreamingReasoning =
|
||||
engine.status === "streaming" &&
|
||||
isLastMessage &&
|
||||
block.index === parts.length - 1
|
||||
return (
|
||||
<Reasoning
|
||||
key={`r-${block.index}`}
|
||||
className="w-full"
|
||||
isStreaming={isStreamingReasoning}
|
||||
defaultOpen={!isRestored}
|
||||
>
|
||||
<ReasoningTrigger
|
||||
getThinkingMessage={thinkingMessage}
|
||||
/>
|
||||
<ReasoningContent>
|
||||
{(part as { text: string }).text}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
}
|
||||
if (block.kind === "tool") {
|
||||
const toolPart = part as unknown as ToolPartLike
|
||||
const validationState =
|
||||
engine.validationStates[toolPart.toolCallId]
|
||||
return (
|
||||
<div key={`t-${block.index}`} className="space-y-2">
|
||||
<ToolActivity
|
||||
part={toolPart}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
{toolPart.type === "tool-display_diagram" &&
|
||||
validationState && (
|
||||
<ValidationCard
|
||||
state={validationState}
|
||||
onImproveWithSuggestions={
|
||||
engine.improveWithSuggestions
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const run = textRun(block.index)
|
||||
if (!run.trim()) return null
|
||||
return (
|
||||
<MessageText
|
||||
key={`x-${block.index}`}
|
||||
text={run}
|
||||
isUser={false}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{!(engine.isBusy && isLastMessage) && (
|
||||
<div className="-ml-1.5 flex items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
|
||||
{text && (
|
||||
<ActionButton
|
||||
label={
|
||||
copied
|
||||
? dict.chat.copied
|
||||
: dict.chat.copyResponse
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await copyText(text)
|
||||
if (!ok)
|
||||
toast.error(dict.chat.failedToCopyDetail)
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</ActionButton>
|
||||
)}
|
||||
{isLastAssistant && !isCachedExample && (
|
||||
<ActionButton
|
||||
label={dict.chat.regenerate}
|
||||
onClick={() => engine.regenerate(index)}
|
||||
>
|
||||
<RotateCcw />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
label={dict.chat.goodResponse}
|
||||
active={feedback === "good"}
|
||||
onClick={() => submitFeedback("good")}
|
||||
>
|
||||
<ThumbsUp
|
||||
className={cn(
|
||||
feedback === "good" && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
label={dict.chat.badResponse}
|
||||
active={feedback === "bad"}
|
||||
onClick={() => submitFeedback("bad")}
|
||||
>
|
||||
<ThumbsDown
|
||||
className={cn(feedback === "bad" && "fill-current")}
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed request: what to do in the text color, the provider's own words
|
||||
* below it, and the action that can fix it first
|
||||
*/
|
||||
function ErrorNotice({
|
||||
text,
|
||||
openModelConfig,
|
||||
onOpenModelConfig,
|
||||
onRetry,
|
||||
}: {
|
||||
text: string
|
||||
openModelConfig: boolean
|
||||
onOpenModelConfig: () => void
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
// Provider errors are "hint\n\nprovider message"
|
||||
const split = text.indexOf("\n\n")
|
||||
const headline = split >= 0 ? text.slice(0, split) : text
|
||||
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
|
||||
const button =
|
||||
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-destructive/25 bg-destructive/5 px-3.5 py-3 text-[13px]"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-line break-words font-medium text-foreground">
|
||||
{headline}
|
||||
</p>
|
||||
{detail && (
|
||||
<p className="mt-1 whitespace-pre-line break-words text-xs text-muted-foreground">
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
{(openModelConfig || onRetry) && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-2">
|
||||
{openModelConfig && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenModelConfig}
|
||||
className={cn(
|
||||
button,
|
||||
"bg-primary text-primary-foreground hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{dict.errors.llm.openModelSettings}
|
||||
</button>
|
||||
)}
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className={cn(
|
||||
button,
|
||||
"border border-border bg-card text-foreground hover:bg-accent",
|
||||
)}
|
||||
data-testid="retry-button"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{dict.chat.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageList() {
|
||||
const dict = useDictionary()
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const engine = useChatEngine()
|
||||
const { messages } = engine
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const prevCountRef = useRef(0)
|
||||
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Keep the newest message in view; jump instantly on bulk loads
|
||||
useEffect(() => {
|
||||
if (!endRef.current || messages.length === 0) return
|
||||
const prev = prevCountRef.current
|
||||
prevCountRef.current = messages.length
|
||||
if (prev === 0 || messages.length - prev > 1) {
|
||||
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
|
||||
return
|
||||
}
|
||||
if (!throttleRef.current) {
|
||||
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
|
||||
throttleRef.current = setTimeout(() => {
|
||||
throttleRef.current = null
|
||||
endRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const lastAssistantIndex = messages
|
||||
.map((m) => m.role)
|
||||
.lastIndexOf("assistant")
|
||||
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-4 pt-4 pb-2">
|
||||
{messages.map((message, index) => {
|
||||
const isRestored = engine.loadedMessageIdsRef.current.has(
|
||||
message.id,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(!isRestored && "animate-message-in")}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<UserMessage
|
||||
message={message}
|
||||
index={index}
|
||||
isLast={index === lastUserIndex}
|
||||
/>
|
||||
) : message.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
index={index}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
isLastAssistant={index === lastAssistantIndex}
|
||||
/>
|
||||
) : (
|
||||
<ErrorNotice
|
||||
text={getMessageTextContent(message)}
|
||||
openModelConfig={
|
||||
!!(
|
||||
message.metadata as
|
||||
| { openModelConfig?: boolean }
|
||||
| undefined
|
||||
)?.openModelConfig
|
||||
}
|
||||
onOpenModelConfig={() => openSettings("models")}
|
||||
onRetry={
|
||||
index === messages.length - 1 &&
|
||||
!engine.isBusy &&
|
||||
lastUserIndex >= 0
|
||||
? engine.retryLastMessage
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{engine.status === "submitted" &&
|
||||
messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="px-1 text-[13px]" aria-live="polite">
|
||||
<Shimmer as="span">{dict.reasoning.thinking}</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
/** A run of plain text, or an attached file / URL whose text was inlined */
|
||||
export interface TextSection {
|
||||
type: "text" | "file" | "url"
|
||||
content: string
|
||||
filename?: string
|
||||
charCount?: number
|
||||
fileType?: "pdf" | "text" | "url"
|
||||
}
|
||||
|
||||
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
|
||||
export const APPENDED_FILE_SECTIONS_PATTERN =
|
||||
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||
|
||||
export function splitTextIntoFileSections(text: string): TextSection[] {
|
||||
const sections: TextSection[] = []
|
||||
const filePattern =
|
||||
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = filePattern.exec(text)) !== null) {
|
||||
const beforeText = text.slice(lastIndex, match.index).trim()
|
||||
if (beforeText) sections.push({ type: "text", content: beforeText })
|
||||
|
||||
const sectionType = match[1].toLowerCase()
|
||||
const fileType =
|
||||
sectionType === "pdf"
|
||||
? "pdf"
|
||||
: sectionType === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
const content = match[3].trim()
|
||||
sections.push({
|
||||
type: sectionType === "url" ? "url" : "file",
|
||||
content,
|
||||
filename: match[2].trim(),
|
||||
charCount: content.length,
|
||||
fileType,
|
||||
})
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
const remainingText = text.slice(lastIndex).trim()
|
||||
if (remainingText) sections.push({ type: "text", content: remainingText })
|
||||
if (sections.length === 0) sections.push({ type: "text", content: text })
|
||||
return sections
|
||||
}
|
||||
|
||||
export function getMessageTextContent(message: UIMessage): string {
|
||||
if (!message.parts) return ""
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part as { text: string }).text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/** Only the user's typed text, without appended file content */
|
||||
export function getUserOriginalText(message: UIMessage): string {
|
||||
return getMessageTextContent(message)
|
||||
.replace(APPENDED_FILE_SECTIONS_PATTERN, "")
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { FileUp, Pencil, Plus, Search, Trash2 } from "lucide-react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatRelativeTime } from "@/lib/relative-time"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/** Title shown for a session, with the default "New Chat" localized */
|
||||
export function useSessionTitle(): string {
|
||||
const dict = useDictionary()
|
||||
const { currentTitle } = useChatEngine()
|
||||
return !currentTitle || currentTitle === "New Chat"
|
||||
? dict.workspace.untitled
|
||||
: currentTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover listing saved diagrams: rename the current one, switch, delete,
|
||||
* start a new one or open a file.
|
||||
*/
|
||||
export function SessionMenu({
|
||||
children,
|
||||
align = "start",
|
||||
onOpenFile,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
align?: "start" | "center" | "end"
|
||||
onOpenFile: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.sessionHistory
|
||||
const params = useParams<{ lang: string }>()
|
||||
const engine = useChatEngine()
|
||||
const open = useUiStore((s) => s.sessionsOpen)
|
||||
const setOpen = useUiStore((s) => s.setSessionsOpen)
|
||||
const title = useSessionTitle()
|
||||
const [query, setQuery] = useState("")
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [draftTitle, setDraftTitle] = useState("")
|
||||
const [toDelete, setToDelete] = useState<string | null>(null)
|
||||
const renameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRenaming(false)
|
||||
setQuery("")
|
||||
}
|
||||
}, [open])
|
||||
useEffect(() => {
|
||||
if (renaming) renameRef.current?.select()
|
||||
}, [renaming])
|
||||
|
||||
const sessions = engine.sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
)
|
||||
|
||||
const commitRename = async () => {
|
||||
const next = draftTitle.trim()
|
||||
setRenaming(false)
|
||||
if (next && next !== title) await engine.renameSession(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
className="flex max-h-[min(560px,80vh)] w-[340px] flex-col overflow-hidden rounded-xl p-0 shadow-pop"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
{renaming ? (
|
||||
<input
|
||||
ref={renameRef}
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
// Enter that picks an IME candidate
|
||||
if (e.nativeEvent.isComposing) return
|
||||
if (e.key === "Enter") commitRename()
|
||||
if (e.key === "Escape") setRenaming(false)
|
||||
}}
|
||||
maxLength={100}
|
||||
aria-label={t.rename}
|
||||
className="h-8 w-full rounded-lg border border-foreground/20 bg-card px-2.5 text-[13px] font-medium outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraftTitle(title)
|
||||
setRenaming(true)
|
||||
}}
|
||||
className="group/rename flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left hover:bg-accent"
|
||||
data-testid="rename-session"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<Pencil className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
engine.newChat()
|
||||
}}
|
||||
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{dict.workspace.newDiagram}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onOpenFile()
|
||||
}}
|
||||
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<FileUp className="size-3.5" />
|
||||
{dict.workspace.openFile}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{engine.sessions.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-faint" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t.searchPlaceholder}
|
||||
className="h-8 w-full rounded-lg bg-muted pr-2.5 pl-8 text-[13px] outline-none placeholder:text-faint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1.5 scrollbar-thin">
|
||||
{engine.sessions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t.noResults}
|
||||
</p>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent =
|
||||
session.id === engine.currentSessionId
|
||||
// The answer being written must stay in its
|
||||
// own chat
|
||||
const locked = engine.isBusy && !isCurrent
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: contains the delete button
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={locked ? -1 : 0}
|
||||
aria-disabled={locked || undefined}
|
||||
title={locked ? t.busyHint : undefined}
|
||||
onClick={() => {
|
||||
if (locked) return
|
||||
setOpen(false)
|
||||
if (!isCurrent)
|
||||
engine.selectSession(session.id)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget)
|
||||
return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
) {
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
if (!isCurrent)
|
||||
engine.selectSession(
|
||||
session.id,
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group/session flex cursor-pointer items-center gap-2.5 rounded-lg p-1.5 hover:bg-accent",
|
||||
isCurrent && "bg-accent",
|
||||
locked &&
|
||||
"cursor-not-allowed opacity-50 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<span className="sheet-light flex h-9 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
|
||||
{session.thumbnailDataUrl && (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={
|
||||
session.thumbnailDataUrl
|
||||
}
|
||||
alt=""
|
||||
className="max-h-8 max-w-11 object-contain"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] text-foreground">
|
||||
{session.title === "New Chat"
|
||||
? dict.workspace.untitled
|
||||
: session.title}
|
||||
</span>
|
||||
<span className="block text-[11px] text-faint">
|
||||
{formatRelativeTime(
|
||||
session.updatedAt,
|
||||
params.lang,
|
||||
t.justNow,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
isCurrent && engine.isBusy
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setToDelete(session.id)
|
||||
}}
|
||||
aria-label={t.deleteTitle}
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-faint [@media(hover:hover)]:opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover/session:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<AlertDialog
|
||||
open={!!toDelete}
|
||||
onOpenChange={(o) => !o && setToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t.deleteDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.common.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
if (toDelete) engine.deleteSession(toDelete)
|
||||
setToDelete(null)
|
||||
}}
|
||||
>
|
||||
{dict.common.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
Copy,
|
||||
Library,
|
||||
PenTool,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { copyText } from "@/lib/clipboard"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { describeChanges } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
export function ToolInputDetails({ part }: { part: ToolPartLike }) {
|
||||
const { input, state } = part
|
||||
if (!input || typeof input !== "object") return null
|
||||
if (typeof input.xml === "string" && input.xml) {
|
||||
// Plain text while streaming; highlighting re-runs on every chunk
|
||||
return state === "input-streaming" || state === "input-available" ? (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground scrollbar-thin">
|
||||
{input.xml}
|
||||
</pre>
|
||||
) : (
|
||||
<CodeBlock code={input.xml} language="xml" />
|
||||
)
|
||||
}
|
||||
if (Array.isArray(input.operations)) {
|
||||
// Streamed or invalid input can hold anything: show only what React
|
||||
// can render (an object in place of a string would crash the chat)
|
||||
const shown = (input.operations as DiagramOperation[]).filter(
|
||||
(op) =>
|
||||
typeof (op as { operation?: unknown })?.operation === "string",
|
||||
)
|
||||
const text = (value: unknown) =>
|
||||
typeof value === "string" ? value : ""
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{shown.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${text(op.cell_id)}-${index}`}
|
||||
className="overflow-hidden rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1 text-[11px]">
|
||||
<span className="font-medium text-foreground">
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{text(op.cell_id)}
|
||||
</span>
|
||||
</div>
|
||||
{text(op.new_xml) && (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
|
||||
{text(op.new_xml)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (Object.keys(input).length === 0) return null
|
||||
return <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
}
|
||||
|
||||
function toolText(part: ToolPartLike): string {
|
||||
const { input } = part
|
||||
if (!input || typeof input !== "object") return ""
|
||||
if (typeof input.xml === "string" && input.xml) return input.xml
|
||||
if (Array.isArray(input.operations)) {
|
||||
return JSON.stringify(input.operations, null, 2)
|
||||
}
|
||||
return JSON.stringify(input, null, 2)
|
||||
}
|
||||
|
||||
/** Thumbnail of a version, or what stands in for it */
|
||||
function VersionThumb({
|
||||
version,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
version: DiagramVersion
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (version.svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={version.svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (version.svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version produced by a tool call. The latest shows
|
||||
* its picture; older ones fold into one row (the picture opens Compare).
|
||||
*/
|
||||
function VersionCard({
|
||||
part,
|
||||
version,
|
||||
index,
|
||||
isLatest,
|
||||
}: {
|
||||
part: ToolPartLike
|
||||
version: DiagramVersion
|
||||
index: number
|
||||
isLatest: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const [showCode, setShowCode] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const isUndone = undoneVersionId === version.id
|
||||
const isOnCanvas = onCanvasVersionId === version.id
|
||||
const isEdit = part.type === "tool-edit_diagram"
|
||||
// Undo/redo belongs to the newest change while it is (or was) on the
|
||||
// canvas; after an older version was restored, this one is restored too
|
||||
const canUndo =
|
||||
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
|
||||
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
|
||||
const summary = version.fromScratch
|
||||
? formatMessage(t.totals, {
|
||||
shapes: version.summary.shapesAdded,
|
||||
edges: version.summary.edgesAdded,
|
||||
})
|
||||
: describeChanges(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{index + 1}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy || isOnCanvas}
|
||||
onClick={() => engine.restoreVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: index + 1 })}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2"
|
||||
data-testid="version-card"
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
data-testid="version-thumb-small"
|
||||
>
|
||||
<VersionThumb
|
||||
version={version}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1" title={summary}>
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
)}
|
||||
data-testid="version-card"
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
version={version}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1" title={summary}>
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-xs text-muted-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
aria-label={showCode ? t.hideCode : t.showCode}
|
||||
title={showCode ? t.hideCode : t.showCode}
|
||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
</button>
|
||||
{canUndo ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => engine.undoVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
>
|
||||
{isUndone ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{isUndone ? t.redo : t.undo}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
</div>
|
||||
{showCode && (
|
||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setCopied(await copyText(toolText(part)))
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||
aria-label={dict.chat.copyResponse}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<ToolInputDetails part={part} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Compact row for tool calls that are running, failed or produced no version */
|
||||
function ToolRow({
|
||||
part,
|
||||
icon,
|
||||
label,
|
||||
tone = "default",
|
||||
detail,
|
||||
}: {
|
||||
part: ToolPartLike
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
tone?: "default" | "error" | "warning"
|
||||
detail?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasInput = !!part.input && Object.keys(part.input).length > 0
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
tone === "error" && "border-destructive/25",
|
||||
)}
|
||||
data-testid="tool-row"
|
||||
data-tool-name={part.type?.replace("tool-", "")}
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={!hasInput && !detail}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
|
||||
tone === "error" &&
|
||||
"bg-destructive/10 text-destructive",
|
||||
tone === "warning" && "bg-marker-soft text-marker-ink",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{(hasInput || detail) && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-2 border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
{detail && (
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words text-xs",
|
||||
tone === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
{hasInput && <ToolInputDetails part={part} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolActivity({
|
||||
part,
|
||||
isLastMessage,
|
||||
}: {
|
||||
part: ToolPartLike
|
||||
isLastMessage: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
const { state, errorText, output } = part
|
||||
|
||||
const versionIndex = versions.findIndex(
|
||||
(v) => v.toolCallId === part.toolCallId,
|
||||
)
|
||||
if (state === "output-available" && versionIndex >= 0) {
|
||||
return (
|
||||
<VersionCard
|
||||
part={part}
|
||||
version={versions[versionIndex]}
|
||||
index={versionIndex}
|
||||
isLatest={versionIndex === versions.length - 1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (toolName === "get_shape_library" && state !== "output-error") {
|
||||
const isDone = state === "output-available"
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={<Library />}
|
||||
label={
|
||||
isDone ? (
|
||||
t.libraryLoaded
|
||||
) : (
|
||||
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
|
||||
)
|
||||
}
|
||||
detail={
|
||||
isDone && typeof output === "string"
|
||||
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "input-streaming" || state === "input-available") {
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={<PenTool />}
|
||||
label={
|
||||
<Shimmer as="span">
|
||||
{toolName === "edit_diagram" ? t.editing : t.drawing}
|
||||
</Shimmer>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "output-error") {
|
||||
const stopped = errorText === "Stopped by user"
|
||||
// Incomplete XML means the output hit the length limit. Without an
|
||||
// input the JSON was broken (the server repairs JSON cut short by
|
||||
// the limit).
|
||||
const truncated =
|
||||
!stopped &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
typeof part.input?.xml === "string" &&
|
||||
!isMxCellXmlComplete(part.input.xml)
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={<AlertCircle />}
|
||||
tone={stopped ? "default" : truncated ? "warning" : "error"}
|
||||
label={
|
||||
stopped
|
||||
? t.stopped
|
||||
: truncated
|
||||
? dict.tools.truncated
|
||||
: isLastMessage && engine.isBusy
|
||||
? t.failedRetrying
|
||||
: t.failed
|
||||
}
|
||||
detail={
|
||||
stopped
|
||||
? undefined
|
||||
: truncated
|
||||
? t.truncatedHint
|
||||
: (errorText ??
|
||||
(typeof output === "string" ? output : ""))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Finished without a version (sessions saved before versions existed)
|
||||
return (
|
||||
<ToolRow
|
||||
part={part}
|
||||
icon={<PenTool />}
|
||||
label={toolName === "edit_diagram" ? t.edited : t.created}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
|
||||
export type { DiagramOperation }
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
@@ -13,4 +11,5 @@ export interface ToolPartLike {
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import type { UIMessage } from "ai"
|
||||
import type { RefObject } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import {
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
replaceNodes,
|
||||
} from "@/lib/utils"
|
||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
|
||||
// Helper to extract complete operations from streaming input
|
||||
function getCompleteOperations(
|
||||
operations: DiagramOperation[] | undefined,
|
||||
): DiagramOperation[] {
|
||||
if (!operations || !Array.isArray(operations)) return []
|
||||
return operations.filter(
|
||||
(op) =>
|
||||
op &&
|
||||
typeof op.operation === "string" &&
|
||||
["update", "add", "delete"].includes(op.operation) &&
|
||||
typeof op.cell_id === "string" &&
|
||||
op.cell_id.length > 0 &&
|
||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||
)
|
||||
}
|
||||
|
||||
interface UseDiagramStreamingParams {
|
||||
messages: UIMessage[]
|
||||
processedToolCallsRef: RefObject<Set<string>>
|
||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||
loadedMessageIdsRef: RefObject<Set<string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws AI tool output onto the canvas while it streams, as a preview that
|
||||
* is not recorded in undo history.
|
||||
*
|
||||
* display_diagram: the complete mxCells written so far. edit_diagram: the
|
||||
* complete operations applied to the diagram from before the call. The tool
|
||||
* handler validates and commits the final result. Runs in the chat engine
|
||||
* so streaming continues while the chat panel is collapsed.
|
||||
*/
|
||||
export function useDiagramStreaming({
|
||||
messages,
|
||||
processedToolCallsRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef,
|
||||
}: UseDiagramStreamingParams) {
|
||||
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
||||
const previousXML = useRef<string>("")
|
||||
// Last processed XML per toolCallId, to skip redundant work while streaming
|
||||
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Reset when messages become empty (new chat or session switch), so
|
||||
// cached examples work again after starting a new session
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
previousXML.current = ""
|
||||
lastProcessedXmlRef.current.clear()
|
||||
processedToolCallsRef.current.clear()
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
}
|
||||
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
|
||||
|
||||
// Streaming preview of display_diagram: draw the complete cells written
|
||||
// so far. The tool handler validates and loads the final diagram.
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string) => {
|
||||
const completeCells = extractCompleteMxCells(xml || "")
|
||||
if (!completeCells) return
|
||||
const convertedXml = convertToLegalXml(completeCells)
|
||||
if (convertedXml === previousXML.current) return
|
||||
|
||||
// Skip this update while the cells written so far don't parse
|
||||
const testDoc = new DOMParser().parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
if (testDoc.querySelector("parsererror")) return
|
||||
|
||||
try {
|
||||
// Replace the first page's cells so other pages stay intact.
|
||||
// An empty canvas gets a default mxfile to put the cells in.
|
||||
const baseXML = chartXML || BLANK_MXFILE
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
previousXML.current = convertedXml
|
||||
loadDiagram(replacedXML, true, "preview")
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
}
|
||||
},
|
||||
[chartXML, loadDiagram],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Only the last message can still be streaming
|
||||
const message = messages[messages.length - 1]
|
||||
if (!message?.parts) return
|
||||
|
||||
// Messages restored from a saved session were applied before it was
|
||||
// saved; the saved diagram is authoritative, so don't replay them
|
||||
if (loadedMessageIdsRef.current.has(message.id)) return
|
||||
|
||||
// The diagram without streamed previews, as loaded last: the tool
|
||||
// handler's result of an earlier edit is there before the chartXML
|
||||
// state catches up. Undoing a failed edit's preview below changes it
|
||||
// too, and an edit streaming right after must start from the undone
|
||||
// diagram.
|
||||
let baseXml = chartXMLRef.current
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (
|
||||
part.type !== "tool-display_diagram" &&
|
||||
part.type !== "tool-edit_diagram"
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const { toolCallId, state, input } = part as ToolPartLike
|
||||
|
||||
// Failed or stopped: if the original XML is still stored, the
|
||||
// tool handler never ran (invalid JSON, or the user pressed
|
||||
// stop), so undo the streamed preview here. Invalid JSON leaves
|
||||
// no input, so check this first.
|
||||
if (state === "output-error") {
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(toolCallId)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(toolCallId)
|
||||
loadDiagram(originalXml, true, "revert")
|
||||
baseXml = originalXml
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Input complete, or the tool handler, a stop or an error took
|
||||
// the call already: the tool handler loads the checked diagram.
|
||||
// The messages update at most every 150 ms (useChat throttle in
|
||||
// the chat engine), so they can still show the call streaming
|
||||
// after that.
|
||||
if (
|
||||
state !== "input-streaming" ||
|
||||
processedToolCallsRef.current.has(toolCallId)
|
||||
) {
|
||||
processedToolCallsRef.current.add(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
lastProcessedXmlRef.current.delete(`${toolCallId}-opCount`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-display_diagram") {
|
||||
const xml = input?.xml as string | undefined
|
||||
// Skip if XML hasn't changed since last processing
|
||||
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
|
||||
continue
|
||||
// Keep the diagram from before the preview, to undo it on a
|
||||
// stop or an error
|
||||
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
|
||||
editDiagramOriginalXmlRef.current.set(
|
||||
toolCallId,
|
||||
baseXml || BLANK_MXFILE,
|
||||
)
|
||||
}
|
||||
handleDisplayChart(xml)
|
||||
lastProcessedXmlRef.current.set(toolCallId, xml)
|
||||
continue
|
||||
}
|
||||
|
||||
// edit_diagram: apply operations incrementally for a preview.
|
||||
// editDiagramOriginalXmlRef is shared with the tool handler.
|
||||
if (!input?.operations) continue
|
||||
const completeOps = getCompleteOperations(
|
||||
input.operations as DiagramOperation[],
|
||||
)
|
||||
if (completeOps.length === 0) continue
|
||||
|
||||
// Capture the original XML when streaming starts
|
||||
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
|
||||
if (!baseXml) {
|
||||
console.warn(
|
||||
"[edit_diagram streaming] No chart XML available",
|
||||
)
|
||||
continue
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
|
||||
}
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(toolCallId)
|
||||
if (!originalXml) continue
|
||||
|
||||
// Skip if no change from last processed state
|
||||
const countKey = `${toolCallId}-opCount`
|
||||
const opCount = String(completeOps.length)
|
||||
if (lastProcessedXmlRef.current.get(countKey) === opCount) continue
|
||||
try {
|
||||
const { result } = applyDiagramOperations(
|
||||
originalXml,
|
||||
completeOps,
|
||||
)
|
||||
// Load the full document so other pages stay intact
|
||||
loadDiagram(result, true, "preview")
|
||||
lastProcessedXmlRef.current.set(countKey, opCount)
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[edit_diagram streaming] Operation failed:",
|
||||
e instanceof Error ? e.message : e,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messages,
|
||||
handleDisplayChart,
|
||||
chartXMLRef,
|
||||
loadDiagram,
|
||||
processedToolCallsRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { RefObject } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { DiagramCommit, LoadMode } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { countCells, diffDiagrams } from "@/lib/diagram-diff"
|
||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
|
||||
interface UseVersionsParams {
|
||||
loadDiagram: (
|
||||
xml: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
) => string | null
|
||||
getVersionSvg: () => Promise<string | null>
|
||||
setCommitHandler: (
|
||||
handler: ((commit: DiagramCommit) => void) | null,
|
||||
) => void
|
||||
/** Index of the user message whose turn is running */
|
||||
currentTurnRef: RefObject<number>
|
||||
/** The document on the canvas now */
|
||||
chartXMLRef: RefObject<string>
|
||||
isBusy: boolean
|
||||
/** Live busy state, for the restore toast's Undo that runs later */
|
||||
isTurnActive: () => boolean
|
||||
/** Changes when another chat comes on screen */
|
||||
getChatGeneration: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns every committed AI change into a version (card in the chat), marks
|
||||
* the changed shapes on the canvas, and restores or undoes versions.
|
||||
*/
|
||||
export function useVersions({
|
||||
loadDiagram,
|
||||
getVersionSvg,
|
||||
setCommitHandler,
|
||||
currentTurnRef,
|
||||
chartXMLRef,
|
||||
isBusy,
|
||||
isTurnActive,
|
||||
getChatGeneration,
|
||||
}: UseVersionsParams) {
|
||||
const dict = useDictionary()
|
||||
// A restore commits too; that path must not add a version
|
||||
const restoringRef = useRef(false)
|
||||
|
||||
// Registered once: re-registering on every render would leave a gap
|
||||
// (effect cleanups run before the streaming hook commits). The handler
|
||||
// only reads refs and stable functions.
|
||||
useEffect(() => {
|
||||
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
|
||||
if (restoringRef.current) return
|
||||
const { summary, touchedIds } = diffDiagrams(beforeXml, afterXml)
|
||||
const before = countCells(beforeXml)
|
||||
const fromScratch = before.shapes + before.edges === 0
|
||||
const id = useVersionsStore.getState().addVersion({
|
||||
xml: afterXml,
|
||||
beforeXml,
|
||||
turnIndex: currentTurnRef.current,
|
||||
toolCallId,
|
||||
summary,
|
||||
fromScratch,
|
||||
})
|
||||
// Mark what changed, unless the whole diagram is new
|
||||
if (!fromScratch) {
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(touchedIds, marker || "#ffd84d")
|
||||
}, 60)
|
||||
}
|
||||
// The picture is of the canvas: take it right away, before a
|
||||
// newer version can be on it; once one is, this one gets none
|
||||
// rather than the newer one's
|
||||
const isNewest = () =>
|
||||
useVersionsStore.getState().versions.at(-1)?.id === id
|
||||
setTimeout(async () => {
|
||||
const svg = isNewest() ? await getVersionSvg() : null
|
||||
useVersionsStore
|
||||
.getState()
|
||||
.updateVersion(id, { svg: svg && isNewest() ? svg : "" })
|
||||
}, 0)
|
||||
})
|
||||
return () => setCommitHandler(null)
|
||||
}, [])
|
||||
|
||||
const apply = (xml: string) => {
|
||||
restoringRef.current = true
|
||||
try {
|
||||
loadDiagram(xml, true, "commit")
|
||||
} finally {
|
||||
restoringRef.current = false
|
||||
}
|
||||
clearHighlights()
|
||||
}
|
||||
|
||||
const restoreVersion = (versionId: string) => {
|
||||
if (isBusy) return
|
||||
const store = useVersionsStore.getState()
|
||||
const index = store.versions.findIndex((v) => v.id === versionId)
|
||||
if (index < 0) return
|
||||
// The canvas before the restore, to go back to it: a multi-page
|
||||
// document is loaded in full, which Ctrl+Z cannot undo
|
||||
const before = chartXMLRef.current
|
||||
const wasOnCanvas = store.onCanvasVersionId
|
||||
const wasUndone = store.undoneVersionId
|
||||
const generation = getChatGeneration()
|
||||
apply(store.versions[index].xml)
|
||||
store.setOnCanvas(versionId)
|
||||
toast.success(
|
||||
formatMessage(dict.versions.restoredVersion, { n: index + 1 }),
|
||||
{
|
||||
id: "version-restored",
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: dict.versions.undoRestore,
|
||||
onClick: () => {
|
||||
// Another chat is on screen, or an answer is
|
||||
// changing the canvas: the old canvas is not theirs
|
||||
if (!before || isTurnActive()) return
|
||||
if (getChatGeneration() !== generation) return
|
||||
apply(before)
|
||||
const now = useVersionsStore.getState()
|
||||
if (wasUndone) now.setUndone(wasUndone)
|
||||
else if (wasOnCanvas) now.setOnCanvas(wasOnCanvas)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Undo the change of the latest version, or redo it when undone */
|
||||
const undoVersion = (versionId: string) => {
|
||||
if (isBusy) return
|
||||
const store = useVersionsStore.getState()
|
||||
const version = store.versions.find((v) => v.id === versionId)
|
||||
if (!version) return
|
||||
if (store.undoneVersionId === versionId) {
|
||||
apply(version.xml)
|
||||
store.setOnCanvas(versionId)
|
||||
return
|
||||
}
|
||||
if (version.beforeXml === undefined) return
|
||||
apply(version.beforeXml || BLANK_MXFILE)
|
||||
store.setUndone(versionId)
|
||||
}
|
||||
|
||||
return { restoreVersion, undoVersion }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/** Row of version thumbnails under the panel header; click to compare */
|
||||
export function VersionStrip() {
|
||||
const dict = useDictionary()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const engine = useChatEngine()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep the newest version in view
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||
}, [versions.length])
|
||||
|
||||
// Versions with no card in the chat: saved by older app versions, or
|
||||
// from a turn that was retried
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some(
|
||||
(p) =>
|
||||
(p as { toolCallId?: string }).toolCallId ===
|
||||
v.toolCallId,
|
||||
),
|
||||
),
|
||||
)
|
||||
// One version with a card: the strip adds nothing to it
|
||||
if (versions.length < 2 && !hasCardless) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{dict.versions.strip}
|
||||
</span>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||
>
|
||||
{versions.map((version, index) => {
|
||||
const isCurrent = version.id === onCanvasVersionId
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
title={`v${index + 1}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
className={cn(
|
||||
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
||||
// An outline with a gap reads on the white
|
||||
// picture in both themes
|
||||
isCurrent &&
|
||||
"outline-2 outline-offset-2 outline-foreground",
|
||||
)}
|
||||
data-testid="version-thumb"
|
||||
>
|
||||
{version.svg && (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={version.svg}
|
||||
alt=""
|
||||
className="max-h-9 max-w-[62px] object-contain"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
|
||||
isCurrent &&
|
||||
"font-semibold text-neutral-900",
|
||||
)}
|
||||
>
|
||||
v{index + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { wrapWithMxFile } from "@/lib/utils"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
// Dev XML presets for streaming simulator
|
||||
const DEV_XML_PRESETS: Record<string, string> = {
|
||||
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
|
||||
})
|
||||
|
||||
// Display the final diagram
|
||||
const fullXml = wrapWithMxFile(xml)
|
||||
onDisplayChart(fullXml)
|
||||
const prepared = prepareNewDiagram(xml)
|
||||
if (prepared.ok) onDisplayChart(prepared.xml)
|
||||
|
||||
setIsSimulating(false)
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Image from "@/components/image-with-basepath"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
|
||||
interface HistoryDialogProps {
|
||||
showHistory: boolean
|
||||
onToggleHistory: (show: boolean) => void
|
||||
}
|
||||
|
||||
export function HistoryDialog({
|
||||
showHistory,
|
||||
onToggleHistory,
|
||||
}: HistoryDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedIndex(null)
|
||||
onToggleHistory(false)
|
||||
}
|
||||
|
||||
const handleConfirmRestore = () => {
|
||||
if (selectedIndex !== null) {
|
||||
// Skip validation for trusted history snapshots
|
||||
onDisplayChart(diagramHistory[selectedIndex].xml, true)
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.history.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.history.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{diagramHistory.length === 0 ? (
|
||||
<div className="text-center p-4 text-gray-500">
|
||||
{dict.history.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
|
||||
{diagramHistory.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
|
||||
selectedIndex === index
|
||||
? "border-primary ring-2 ring-primary"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
|
||||
<Image
|
||||
src={item.svg}
|
||||
alt={`${dict.history.version} ${index + 1}`}
|
||||
width={200}
|
||||
height={100}
|
||||
className="object-contain w-full h-full p-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-center mt-1 text-gray-500">
|
||||
{dict.history.version} {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIndex !== null ? (
|
||||
<>
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{formatMessage(dict.history.restoreTo, {
|
||||
version: selectedIndex + 1,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedIndex(null)}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRestore}>
|
||||
{dict.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{dict.common.close}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+1170
-889
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import {
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector"
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
@@ -66,6 +67,22 @@ function groupModelsByProvider(
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* The model id without the provider or region prefix that the logo already
|
||||
* shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5"
|
||||
* become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the
|
||||
* tooltip and the list.
|
||||
*/
|
||||
export function shortModelName(id: string): string {
|
||||
const name = id.slice(id.lastIndexOf("/") + 1)
|
||||
return (
|
||||
name.replace(
|
||||
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
|
||||
"",
|
||||
) || name
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
selectedModelId,
|
||||
@@ -158,7 +175,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -167,18 +184,26 @@ export function ModelSelector({
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
aria-label={tooltipContent}
|
||||
data-testid="model-selector"
|
||||
>
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{selectedModel ? (
|
||||
<ProviderLogo
|
||||
provider={selectedModel.provider}
|
||||
className="size-3.5 flex-shrink-0 opacity-80"
|
||||
/>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
? shortModelName(selectedModel.modelId)
|
||||
: dict.modelConfig.default}
|
||||
</span>
|
||||
) : (
|
||||
@@ -264,9 +289,13 @@ export function ModelSelector({
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
@@ -351,9 +380,11 @@ export function ModelSelector({
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
@@ -26,7 +31,7 @@ export type SecretField =
|
||||
| "vertexApiKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
const AWS_REGIONS: Array<[string, string]> = [
|
||||
export const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
["us-east-2", "Ohio"],
|
||||
["us-west-2", "Oregon"],
|
||||
@@ -77,6 +82,7 @@ export function ProviderCredentialsFields({
|
||||
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
@@ -208,17 +214,29 @@ export function ProviderCredentialsFields({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
{/* API Key, with a link to where the provider issues keys */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
@@ -238,12 +256,28 @@ export function ProviderCredentialsFields({
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
// Drop a pasted endpoint path such as /chat/completions
|
||||
onBlur={(e) => {
|
||||
const normalized = normalizeBaseUrl(
|
||||
e.target.value,
|
||||
)
|
||||
if (normalized !== e.target.value) {
|
||||
onChange("baseUrl", normalized)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{requestUrl && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
|
||||
interface ResetWarningModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function ResetWarningModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onClear,
|
||||
}: ResetWarningModalProps) {
|
||||
const dict = useDictionary()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{dict.dialogs.clearDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onClear}>
|
||||
{dict.dialogs.clearEverything}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+46
-37
@@ -10,15 +10,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||
|
||||
@@ -62,21 +55,25 @@ export function SaveDialog({
|
||||
{
|
||||
value: "drawio" as const,
|
||||
label: dict.save.formats.drawio,
|
||||
hint: dict.save.hints.drawio,
|
||||
extension: ".drawio",
|
||||
},
|
||||
{
|
||||
value: "png" as const,
|
||||
label: dict.save.formats.png,
|
||||
hint: dict.save.hints.png,
|
||||
extension: ".png",
|
||||
},
|
||||
{
|
||||
value: "svg" as const,
|
||||
label: dict.save.formats.svg,
|
||||
hint: dict.save.hints.svg,
|
||||
extension: ".svg",
|
||||
},
|
||||
{
|
||||
value: "xmlsvg" as const,
|
||||
label: dict.save.formats.xmlsvg,
|
||||
hint: dict.save.hints.xmlsvg,
|
||||
extension: ".drawio.svg",
|
||||
},
|
||||
]
|
||||
@@ -85,7 +82,7 @@ export function SaveDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dict.save.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -93,44 +90,54 @@ export function SaveDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{dict.save.format}
|
||||
</label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as ExportFormat)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={dict.save.format}
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: styled radio tile
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === opt.value}
|
||||
onClick={() => setFormat(opt.value)}
|
||||
className={cn(
|
||||
"rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/30",
|
||||
format === opt.value &&
|
||||
"border-foreground bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="block text-[13px] font-medium text-foreground">
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{opt.hint}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
<label
|
||||
htmlFor="export-filename"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.save.filename}
|
||||
</label>
|
||||
<div className="flex items-stretch">
|
||||
<Input
|
||||
{/* One field: the name, then its extension */}
|
||||
<div className="flex h-9 items-center overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring/40">
|
||||
<input
|
||||
id="export-filename"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={dict.save.filenamePlaceholder}
|
||||
autoFocus
|
||||
onFocus={(e) => e.target.select()}
|
||||
className="rounded-r-none border-r-0 focus-visible:z-10"
|
||||
className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
/>
|
||||
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono">
|
||||
<span className="shrink-0 pr-3 text-sm text-muted-foreground">
|
||||
{currentFormat?.extension || ".drawio"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -143,7 +150,9 @@ export function SaveDialog({
|
||||
>
|
||||
{dict.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{dict.common.save}</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{dict.save.exportButton}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
// Reusable setting item component for consistent layout
|
||||
function SettingItem({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 first:pt-0 last:pb-0">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-[260px]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
"zh-Hant": "繁體中文",
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
drawioUi: DrawioTheme
|
||||
onDrawioUiChange: (theme: DrawioTheme) => void
|
||||
darkMode: boolean
|
||||
onToggleDarkMode: () => void
|
||||
minimalStyle?: boolean
|
||||
onMinimalStyleChange?: (value: boolean) => void
|
||||
vlmValidationEnabled?: boolean
|
||||
onVlmValidationChange?: (value: boolean) => void
|
||||
onOpenModelConfig?: () => void
|
||||
customSystemMessage?: string
|
||||
onCustomSystemMessageChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const stored = localStorage.getItem(STORAGE_ACCESS_CODE_REQUIRED_KEY)
|
||||
if (stored === null) return null
|
||||
return stored === "true"
|
||||
}
|
||||
|
||||
function SettingsContent({
|
||||
open,
|
||||
onOpenChange,
|
||||
drawioUi,
|
||||
onDrawioUiChange,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
minimalStyle = false,
|
||||
onMinimalStyleChange = () => {},
|
||||
vlmValidationEnabled = false,
|
||||
onVlmValidationChange = () => {},
|
||||
onOpenModelConfig,
|
||||
customSystemMessage = "",
|
||||
onCustomSystemMessageChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [currentLang, setCurrentLang] = useState("en")
|
||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||
|
||||
// Panel visibility state
|
||||
const [showRecentChats, setShowRecentChats] = useState(true)
|
||||
const [showMyTemplates, setShowMyTemplates] = useState(true)
|
||||
const [showQuickExamples, setShowQuickExamples] = useState(true)
|
||||
|
||||
const handlePanelToggle = useCallback(
|
||||
(key: string, value: boolean, setter: (v: boolean) => void) => {
|
||||
setter(value)
|
||||
localStorage.setItem(key, String(value))
|
||||
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Proxy settings state (Electron only)
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
const [httpsProxy, setHttpsProxy] = useState("")
|
||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Re-fetch config whenever the dialog opens to ensure we always show
|
||||
// the access code input if the server requires it. This fixes the case
|
||||
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
|
||||
// configured) would hide the access code input.
|
||||
if (!open) return
|
||||
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const required = data?.accessCodeRequired === true
|
||||
localStorage.setItem(
|
||||
STORAGE_ACCESS_CODE_REQUIRED_KEY,
|
||||
String(required),
|
||||
)
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep existing cached value on error
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Detect current language from pathname
|
||||
useEffect(() => {
|
||||
const seg = pathname.split("/").filter(Boolean)
|
||||
const first = seg[0]
|
||||
if (first && i18n.locales.includes(first as Locale)) {
|
||||
setCurrentLang(first)
|
||||
} else {
|
||||
setCurrentLang(i18n.defaultLocale)
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const storedCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
setAccessCode(storedCode)
|
||||
|
||||
const storedSendShortcut = localStorage.getItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
)
|
||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||
|
||||
setShowRecentChats(
|
||||
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||
)
|
||||
setShowMyTemplates(
|
||||
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||
)
|
||||
setShowQuickExamples(
|
||||
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
||||
"false",
|
||||
)
|
||||
|
||||
setError("")
|
||||
|
||||
// Load proxy settings (Electron only)
|
||||
if (window.electronAPI?.getProxy) {
|
||||
window.electronAPI.getProxy().then((config) => {
|
||||
setHttpProxy(config.httpProxy || "")
|
||||
setHttpsProxy(config.httpsProxy || "")
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const changeLanguage = (lang: string) => {
|
||||
// Save locale to localStorage for persistence across restarts
|
||||
localStorage.setItem("next-ai-draw-io-locale", lang)
|
||||
|
||||
// Notify Electron main process to update its menu language
|
||||
if (window.electronAPI?.setUserLocale) {
|
||||
window.electronAPI.setUserLocale(lang).catch((error) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const newPath = parts.join("/") || "/"
|
||||
const searchStr = search?.toString() ? `?${search.toString()}` : ""
|
||||
router.push(newPath + searchStr)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessCodeRequired) return
|
||||
|
||||
setError("")
|
||||
setIsVerifying(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/verify-access-code"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-access-code": accessCode.trim(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.valid) {
|
||||
setError(data.message || dict.errors.invalidAccessCode)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
setError(dict.errors.networkError)
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyProxy = async () => {
|
||||
if (!window.electronAPI?.setProxy) return
|
||||
|
||||
// Validate proxy URLs (must start with http:// or https://)
|
||||
const validateProxyUrl = (url: string): boolean => {
|
||||
if (!url) return true // Empty is OK
|
||||
return url.startsWith("http://") || url.startsWith("https://")
|
||||
}
|
||||
|
||||
const trimmedHttp = httpProxy.trim()
|
||||
const trimmedHttps = httpsProxy.trim()
|
||||
|
||||
if (trimmedHttp && !validateProxyUrl(trimmedHttp)) {
|
||||
toast.error("HTTP Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
if (trimmedHttps && !validateProxyUrl(trimmedHttps)) {
|
||||
toast.error("HTTPS Proxy must start with http:// or https://")
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingProxy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.setProxy({
|
||||
httpProxy: trimmedHttp || undefined,
|
||||
httpsProxy: trimmedHttps || undefined,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success(dict.settings.proxyApplied)
|
||||
} else {
|
||||
toast.error(result.error || "Failed to apply proxy settings")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to apply proxy settings")
|
||||
} finally {
|
||||
setIsApplyingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{dict.settings.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||
<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">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="access-code"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.accessCode}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.accessCodeDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
dict.settings.accessCodePlaceholder
|
||||
}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 px-4 rounded-xl"
|
||||
>
|
||||
{isVerifying ? "..." : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language */}
|
||||
<SettingItem
|
||||
label={dict.settings.language}
|
||||
description={dict.settings.languageDescription}
|
||||
>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Theme */}
|
||||
<SettingItem
|
||||
label={dict.settings.theme}
|
||||
description={dict.settings.themeDescription}
|
||||
>
|
||||
<Button
|
||||
id="theme-toggle"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onToggleDarkMode}
|
||||
className="h-9 w-9 rounded-xl border-border-subtle hover:bg-interactive-hover"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Draw.io Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.drawioStyle}
|
||||
description={dict.settings.drawioStyleDescription}
|
||||
>
|
||||
<Select
|
||||
value={drawioUi}
|
||||
onValueChange={(v) =>
|
||||
onDrawioUiChange(v as DrawioTheme)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="drawio-ui-select"
|
||||
aria-label={dict.settings.drawioStyle}
|
||||
className="w-[120px] h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kennedy">
|
||||
{dict.settings.themeDefault}
|
||||
</SelectItem>
|
||||
<SelectItem value="atlas">Atlas</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{dict.settings.themeDark}
|
||||
</SelectItem>
|
||||
<SelectItem value="min">
|
||||
{dict.settings.themeMinimal}
|
||||
</SelectItem>
|
||||
<SelectItem value="sketch">
|
||||
{dict.settings.themeSketch}
|
||||
</SelectItem>
|
||||
<SelectItem value="simple">
|
||||
{dict.settings.themeSimple}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Diagram Style */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramStyle}
|
||||
description={dict.settings.diagramStyleDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="minimal-style"
|
||||
checked={minimalStyle}
|
||||
onCheckedChange={onMinimalStyleChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{minimalStyle
|
||||
? dict.chat.minimalStyle
|
||||
: dict.chat.styledMode}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Panel Visibility */}
|
||||
<SettingItem
|
||||
label={dict.settings.panelVisibility}
|
||||
description={dict.settings.panelVisibilityDescription}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-recent-chats"
|
||||
checked={showRecentChats}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showRecentChats,
|
||||
v,
|
||||
setShowRecentChats,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showRecentChats}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-my-templates"
|
||||
checked={showMyTemplates}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showMyTemplates,
|
||||
v,
|
||||
setShowMyTemplates,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showMyTemplates}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Switch
|
||||
id="show-quick-examples"
|
||||
checked={showQuickExamples}
|
||||
onCheckedChange={(v) =>
|
||||
handlePanelToggle(
|
||||
STORAGE_KEYS.showQuickExamples,
|
||||
v,
|
||||
setShowQuickExamples,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dict.settings.showQuickExamples}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* VLM Diagram Validation */}
|
||||
<SettingItem
|
||||
label={dict.settings.diagramValidation}
|
||||
description={dict.settings.diagramValidationDescription}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="vlm-validation"
|
||||
checked={vlmValidationEnabled}
|
||||
onCheckedChange={onVlmValidationChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{vlmValidationEnabled
|
||||
? dict.settings.enabled
|
||||
: dict.settings.disabled}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{/* Custom System Message */}
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="custom-system-message"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{dict.settings.customSystemMessage}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.customSystemMessageDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={customSystemMessage}
|
||||
onChange={(e) =>
|
||||
onCustomSystemMessageChange(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
dict.settings.customSystemMessagePlaceholder
|
||||
}
|
||||
className="min-h-[80px] max-h-[160px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
description={dict.settings.sendShortcutDescription}
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) => {
|
||||
setSendShortcut(value)
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.sendShortcut,
|
||||
value,
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sendShortcutChange", {
|
||||
detail: value,
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="w-auto h-9 rounded-xl"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{dict.settings.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{dict.settings.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
|
||||
{/* Proxy Settings - Electron only */}
|
||||
{typeof window !== "undefined" &&
|
||||
window.electronAPI?.isElectron && (
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{dict.settings.proxy}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.settings.proxyDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
type="text"
|
||||
value={httpProxy}
|
||||
onChange={(e) =>
|
||||
setHttpProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
type="text"
|
||||
value={httpsProxy}
|
||||
onChange={(e) =>
|
||||
setHttpsProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${dict.settings.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleApplyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 px-4 rounded-xl w-full"
|
||||
>
|
||||
{isApplyingProxy
|
||||
? "..."
|
||||
: dict.settings.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border-subtle bg-surface-1/50 rounded-b-2xl">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{process.env.APP_VERSION}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub
|
||||
</a>
|
||||
{process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE ===
|
||||
"true" && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<a
|
||||
href={`/${currentLang}/about${currentLang === "zh" ? "/cn" : currentLang === "ja" ? "/ja" : ""}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
{dict.nav.about}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<DialogContent className="sm:max-w-lg p-0">
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
}
|
||||
>
|
||||
<SettingsContent {...props} />
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import {
|
||||
Github,
|
||||
Info,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type SendShortcut,
|
||||
type ThemePreference,
|
||||
useSettingsStore,
|
||||
} from "@/stores/settings-store"
|
||||
import { type SettingsTab, useUiStore } from "@/stores/ui-store"
|
||||
|
||||
const LANGUAGE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
"zh-Hant": "繁體中文",
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
description,
|
||||
htmlFor,
|
||||
children,
|
||||
stacked = false,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
htmlFor?: string
|
||||
children: React.ReactNode
|
||||
stacked?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"py-4 first:pt-1",
|
||||
stacked
|
||||
? "space-y-3"
|
||||
: "flex items-center justify-between gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="block text-[13px] font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="max-w-[34em] text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(!stacked && "shrink-0")}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
options: { value: T; label: string; icon?: React.ReactNode }[]
|
||||
onChange: (value: T) => void
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className="inline-flex rounded-lg bg-muted p-0.5"
|
||||
>
|
||||
{options.map((option) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: styled segmented control
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs text-muted-foreground transition-colors [&_svg]:size-3.5",
|
||||
value === option.value &&
|
||||
"bg-card font-medium text-foreground shadow-float",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceTab() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() || "/"
|
||||
const search = useSearchParams()
|
||||
const theme = useSettingsStore((s) => s.theme)
|
||||
const setTheme = useSettingsStore((s) => s.setTheme)
|
||||
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
|
||||
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
|
||||
const currentLang =
|
||||
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
|
||||
|
||||
const changeLanguage = (lang: string) => {
|
||||
localStorage.setItem(STORAGE_KEYS.locale, lang)
|
||||
// The page reloads in the new language; close settings as before
|
||||
useUiStore.getState().setSettingsOpen(false)
|
||||
// Keep the desktop app's menu language in sync
|
||||
window.electronAPI?.setUserLocale?.(lang).catch((error: unknown) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const query = search?.toString() ? `?${search.toString()}` : ""
|
||||
router.push((parts.join("/") || "/") + query)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.theme} description={t.themeDescription}>
|
||||
<Segmented<ThemePreference>
|
||||
label={t.theme}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: "light", label: t.themeLight, icon: <Sun /> },
|
||||
{
|
||||
value: "dark",
|
||||
label: t.themeDarkMode,
|
||||
icon: <Moon />,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t.themeSystem,
|
||||
icon: <Monitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t.language} description={t.languageDescription}>
|
||||
<Select value={currentLang} onValueChange={changeLanguage}>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="h-8 w-[132px] rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
||||
<Segmented<"styled" | "minimal">
|
||||
label={t.diagramStyle}
|
||||
value={minimalStyle ? "minimal" : "styled"}
|
||||
onChange={(value) => setMinimalStyle(value === "minimal")}
|
||||
options={[
|
||||
{ value: "styled", label: dict.chat.styledMode },
|
||||
{ value: "minimal", label: dict.chat.minimalStyle },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedTab({ open }: { open: boolean }) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const settings = useSettingsStore()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
|
||||
)
|
||||
const [isVerifying, setIsVerifying] = useState(false)
|
||||
const [accessError, setAccessError] = useState("")
|
||||
const [httpProxy, setHttpProxy] = useState("")
|
||||
const [httpsProxy, setHttpsProxy] = useState("")
|
||||
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!window.electronAPI?.isElectron
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setAccessCode(localStorage.getItem(STORAGE_KEYS.accessCode) || "")
|
||||
setAccessError("")
|
||||
// Re-check on every open: a stale cached value would hide the field
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const required = data?.accessCodeRequired === true
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.accessCodeRequired,
|
||||
String(required),
|
||||
)
|
||||
setAccessCodeRequired(required)
|
||||
})
|
||||
.catch(() => {})
|
||||
window.electronAPI?.getProxy?.().then((config: any) => {
|
||||
setHttpProxy(config.httpProxy || "")
|
||||
setHttpsProxy(config.httpsProxy || "")
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const saveAccessCode = async () => {
|
||||
setAccessError("")
|
||||
setIsVerifying(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/verify-access-code"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "x-access-code": accessCode.trim() },
|
||||
},
|
||||
)
|
||||
const data = await response.json()
|
||||
if (!data.valid) {
|
||||
setAccessError(data.message || dict.errors.invalidAccessCode)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEYS.accessCode, accessCode.trim())
|
||||
toast.success(t.accessCodeSaved)
|
||||
} catch {
|
||||
setAccessError(dict.errors.networkError)
|
||||
} finally {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyProxy = async () => {
|
||||
if (!window.electronAPI?.setProxy) return
|
||||
const isValid = (url: string) =>
|
||||
!url || url.startsWith("http://") || url.startsWith("https://")
|
||||
const http = httpProxy.trim()
|
||||
const https = httpsProxy.trim()
|
||||
if (!isValid(http) || !isValid(https)) {
|
||||
toast.error(t.proxyInvalid)
|
||||
return
|
||||
}
|
||||
setIsApplyingProxy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.setProxy({
|
||||
httpProxy: http || undefined,
|
||||
httpsProxy: https || undefined,
|
||||
})
|
||||
if (result.success) toast.success(t.proxyApplied)
|
||||
else toast.error(result.error || t.proxyFailed)
|
||||
} catch {
|
||||
toast.error(t.proxyFailed)
|
||||
} finally {
|
||||
setIsApplyingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{accessCodeRequired && (
|
||||
<Row
|
||||
label={t.accessCode}
|
||||
description={t.accessCodeDescription}
|
||||
htmlFor="access-code"
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) => setAccessCode(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
saveAccessCode()
|
||||
}
|
||||
}}
|
||||
placeholder={t.accessCodePlaceholder}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={saveAccessCode}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isVerifying ? "…" : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{accessError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{accessError}
|
||||
</p>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
|
||||
<Select
|
||||
value={settings.sendShortcut}
|
||||
onValueChange={(value) =>
|
||||
settings.setSendShortcut(value as SendShortcut)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="h-8 w-auto rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">{t.enterToSend}</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{t.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.diagramValidation}
|
||||
description={t.diagramValidationDescription}
|
||||
htmlFor="vlm-validation"
|
||||
>
|
||||
<Switch
|
||||
id="vlm-validation"
|
||||
checked={settings.vlmValidationEnabled}
|
||||
onCheckedChange={settings.setVlmValidationEnabled}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.maxOutputTokens}
|
||||
description={t.maxOutputTokensDescription}
|
||||
htmlFor="max-output-tokens"
|
||||
>
|
||||
<Input
|
||||
id="max-output-tokens"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settings.maxOutputTokens}
|
||||
onChange={(e) =>
|
||||
settings.setMaxOutputTokens(e.target.value)
|
||||
}
|
||||
placeholder="64000"
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.customSystemMessage}
|
||||
description={t.customSystemMessageDescription}
|
||||
htmlFor="custom-system-message"
|
||||
stacked
|
||||
>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={settings.customSystemMessage}
|
||||
onChange={(e) =>
|
||||
settings.setCustomSystemMessage(e.target.value)
|
||||
}
|
||||
placeholder={t.customSystemMessagePlaceholder}
|
||||
className="max-h-[180px] min-h-[96px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Row>
|
||||
{isElectron && (
|
||||
<Row label={t.proxy} description={t.proxyDescription} stacked>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
value={httpProxy}
|
||||
onChange={(e) => setHttpProxy(e.target.value)}
|
||||
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
value={httpsProxy}
|
||||
onChange={(e) => setHttpsProxy(e.target.value)}
|
||||
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={applyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 w-full rounded-lg"
|
||||
>
|
||||
{isApplyingProxy ? "…" : t.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutTab() {
|
||||
const dict = useDictionary()
|
||||
const pathname = usePathname() || "/"
|
||||
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
||||
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandMark className="size-10 rounded-xl" />
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold">
|
||||
Next AI Draw.io
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{dict.settings.appVersion} {process.env.APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* MCP server card, hidden on self-hosted deployments */}
|
||||
{!isSelfHosted && (
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl border border-border p-3.5 transition-colors hover:border-foreground/25"
|
||||
>
|
||||
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Terminal className="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px] font-medium">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{dict.examples.mcpDescription}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Github className="size-3.5" />
|
||||
GitHub
|
||||
</a>
|
||||
{showAbout && (
|
||||
<a
|
||||
href={aboutPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
{dict.settings.projectWebsite}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsDialog() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const engine = useChatEngine()
|
||||
const open = useUiStore((s) => s.settingsOpen)
|
||||
const setOpen = useUiStore((s) => s.setSettingsOpen)
|
||||
const tab = useUiStore((s) => s.settingsTab)
|
||||
const setTab = useUiStore((s) => s.setSettingsTab)
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
|
||||
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
|
||||
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
|
||||
{ id: "about", label: t.tabAbout, icon: <Info /> },
|
||||
]
|
||||
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
|
||||
{/* On top below 1024 px: beside the model settings' own
|
||||
provider list it would leave too little width */}
|
||||
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
|
||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
|
||||
{t.title}
|
||||
</DialogTitle>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
|
||||
tab === item.id &&
|
||||
"bg-card font-medium text-foreground shadow-float",
|
||||
)}
|
||||
data-testid={`settings-tab-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<DialogDescription className="sr-only">
|
||||
{t.description}
|
||||
</DialogDescription>
|
||||
{tab === "models" ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ModelConfigDialog
|
||||
embedded
|
||||
open={open}
|
||||
onOpenChange={() => {}}
|
||||
modelConfig={engine.modelConfig}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
|
||||
<h2 className="mb-2 text-[15px] font-semibold">
|
||||
{current.label}
|
||||
</h2>
|
||||
{tab === "appearance" && <AppearanceTab />}
|
||||
{tab === "advanced" && <AdvancedTab open={open} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { Toaster } from "sonner"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/** The single toast container, mounted once at the root */
|
||||
export function AppToaster() {
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
return (
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
// Show stacked toasts in full, so a later one never covers the
|
||||
// button of an earlier one (e.g. "Continue without saving")
|
||||
expand
|
||||
theme={isDark ? "dark" : "light"}
|
||||
offset={{ bottom: 20 }}
|
||||
mobileOffset={{ bottom: 72 }}
|
||||
toastOptions={{
|
||||
duration: 2500,
|
||||
style: { maxWidth: "480px" },
|
||||
classNames: {
|
||||
toast: "!rounded-xl !border-border !bg-card !text-foreground !shadow-pop",
|
||||
description: "!text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** The app icon in a rounded tile; the white variant is used in dark mode */
|
||||
export function BrandMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
|
||||
<img
|
||||
src={getAssetUrl("/favicon-192x192.png")}
|
||||
alt=""
|
||||
className="size-full object-contain dark:hidden"
|
||||
/>
|
||||
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
|
||||
<img
|
||||
src={getAssetUrl("/favicon-white.svg")}
|
||||
alt=""
|
||||
className="hidden size-5 object-contain dark:block"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { forwardRef } from "react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface IconButtonProps extends React.ComponentProps<"button"> {
|
||||
label: string
|
||||
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
||||
shortcut?: string
|
||||
active?: boolean
|
||||
tooltipSide?: "top" | "bottom" | "left" | "right"
|
||||
size?: "sm" | "md"
|
||||
}
|
||||
|
||||
/** Square icon button with a tooltip; the tooltip text is also its label */
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton(
|
||||
{
|
||||
label,
|
||||
shortcut,
|
||||
active = false,
|
||||
tooltipSide = "bottom",
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active || undefined}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
||||
"disabled:pointer-events-none disabled:opacity-35",
|
||||
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
|
||||
size === "md" ? "size-8" : "size-7",
|
||||
// A tinted tile: solid ink is for the main action
|
||||
active &&
|
||||
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={tooltipSide}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{label}
|
||||
{shortcut && (
|
||||
<kbd className="font-sans text-[11px] opacity-60">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Thin vertical divider between toolbar groups */
|
||||
export function ToolbarDivider() {
|
||||
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client"
|
||||
|
||||
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
CanvasStage,
|
||||
DIAGRAM_FILE_ACCEPT,
|
||||
} from "@/components/canvas/canvas-stage"
|
||||
import {
|
||||
ChatEngineProvider,
|
||||
useChatEngine,
|
||||
} from "@/components/chat/chat-engine"
|
||||
import { ChatPanel } from "@/components/chat/chat-panel"
|
||||
import { CompareDialog } from "@/components/chat/compare-dialog"
|
||||
import { LobbyHero } from "@/components/chat/lobby-hero"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { SettingsDialog } from "@/components/settings/settings-dialog"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
debugCellIds,
|
||||
debugView,
|
||||
setAppShortcutHandler,
|
||||
toggleShapesPanel,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { modKey } from "@/lib/platform"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { cn, isRealDiagram } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
// Gap between the floating panel and the window edge
|
||||
const PANEL_GAP = 12
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(
|
||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||||
)
|
||||
const update = () => setIsMobile(query.matches)
|
||||
update()
|
||||
query.addEventListener("change", update)
|
||||
return () => query.removeEventListener("change", update)
|
||||
}, [])
|
||||
return isMobile
|
||||
}
|
||||
|
||||
/** Restore the language picked last time (the URL may say otherwise) */
|
||||
function useSavedLocale() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (!saved || !i18n.locales.includes(saved as Locale)) return
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts[0] === saved) return
|
||||
parts[0] = saved
|
||||
const { search, hash } = window.location
|
||||
router.replace(`/${parts.join("/")}${search}${hash}`)
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Stores on window in development, for debugging and end-to-end tests
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
||||
;(window as any).__naiStores = {
|
||||
canvas: useCanvasStore,
|
||||
versions: useVersionsStore,
|
||||
ui: useUiStore,
|
||||
settings: useSettingsStore,
|
||||
cellIds: debugCellIds,
|
||||
view: debugView,
|
||||
}
|
||||
}
|
||||
|
||||
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
|
||||
export function exportFilename(title: string | null): string {
|
||||
const name = (title && title !== "New Chat" ? title : "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
// Local date (toISOString would give UTC)
|
||||
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
|
||||
}
|
||||
|
||||
/** The whole editor: canvas, chat panel, dialogs */
|
||||
export function Workspace() {
|
||||
const hydrate = useSettingsStore((s) => s.hydrate)
|
||||
useEffect(() => {
|
||||
hydrate()
|
||||
}, [hydrate])
|
||||
useSavedLocale()
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={350}>
|
||||
<ChatEngineProvider>
|
||||
<WorkspaceLayout />
|
||||
</ChatEngineProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceLayout() {
|
||||
const dict = useDictionary()
|
||||
const engine = useChatEngine()
|
||||
const { chartXML, saveDiagramToFile } = useDiagram()
|
||||
const isMobile = useIsMobile()
|
||||
const panelWidth = useSettingsStore((s) => s.panelWidth)
|
||||
const {
|
||||
panelOpen,
|
||||
togglePanel,
|
||||
setPanelOpen,
|
||||
mobileView,
|
||||
setMobileView,
|
||||
openSettings,
|
||||
focusComposer,
|
||||
saveDialogOpen,
|
||||
setSaveDialogOpen,
|
||||
} = useUiStore()
|
||||
const [heroDismissed, setHeroDismissed] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Start screen: nothing said and nothing drawn yet
|
||||
const showHero =
|
||||
engine.isRestored &&
|
||||
engine.messages.length === 0 &&
|
||||
!isRealDiagram(chartXML) &&
|
||||
!heroDismissed
|
||||
|
||||
// A new conversation brings the start screen back (a new chat gets a
|
||||
// new tracing id, also when it was empty before)
|
||||
const chatId = engine.langfuseSessionId
|
||||
useEffect(() => {
|
||||
setHeroDismissed(false)
|
||||
}, [chatId])
|
||||
const messageCount = engine.messages.length
|
||||
const wasEmptyRef = useRef(true)
|
||||
useEffect(() => {
|
||||
if (messageCount > 0) wasEmptyRef.current = false
|
||||
else if (!wasEmptyRef.current) {
|
||||
wasEmptyRef.current = true
|
||||
setHeroDismissed(false)
|
||||
}
|
||||
}, [messageCount])
|
||||
|
||||
// Phones: show the canvas while the AI draws and when a new version
|
||||
// lands, so the result is visible; the chat tab gets a dot meanwhile
|
||||
const isBusy = engine.isBusy
|
||||
const versionCount = useVersionsStore((s) => s.versions.length)
|
||||
const [chatUnread, setChatUnread] = useState(false)
|
||||
const lastVersionCountRef = useRef(versionCount)
|
||||
useEffect(() => {
|
||||
if (isMobile && isBusy) setMobileView("canvas")
|
||||
}, [isMobile, isBusy, setMobileView])
|
||||
useEffect(() => {
|
||||
const grew = versionCount > lastVersionCountRef.current
|
||||
lastVersionCountRef.current = versionCount
|
||||
if (isMobile && grew && engine.isRestored) {
|
||||
setMobileView("canvas")
|
||||
setChatUnread(true)
|
||||
}
|
||||
}, [versionCount, isMobile, engine.isRestored, setMobileView])
|
||||
useEffect(() => {
|
||||
if (mobileView === "chat") setChatUnread(false)
|
||||
}, [mobileView])
|
||||
|
||||
// ⌘B toggles the panel; ⌘/ asks the AI about the selection; ⌘⇧K
|
||||
// shows the shape library
|
||||
useEffect(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) return false
|
||||
if (event.shiftKey && (event.key === "k" || event.key === "K")) {
|
||||
toggleShapesPanel()
|
||||
return true
|
||||
}
|
||||
if (event.key === "b" || event.key === "B") {
|
||||
if (isMobile) return false
|
||||
togglePanel()
|
||||
return true
|
||||
}
|
||||
if (event.key === "/") {
|
||||
setHeroDismissed(true)
|
||||
focusComposer()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (handle(event)) event.preventDefault()
|
||||
}
|
||||
// draw.io takes focus when it (re)loads, e.g. after a language
|
||||
// switch; Escape typed there must still close our dialogs.
|
||||
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
|
||||
const handleInFrame = (event: KeyboardEvent) => {
|
||||
if (event.key === "b" || event.key === "B") return false
|
||||
if (event.key === "Escape") {
|
||||
const ui = useUiStore.getState()
|
||||
if (
|
||||
ui.settingsOpen ||
|
||||
ui.compareVersionId ||
|
||||
ui.saveDialogOpen
|
||||
) {
|
||||
ui.setSettingsOpen(false)
|
||||
ui.closeCompare()
|
||||
ui.setSaveDialogOpen(false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return handle(event)
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
setAppShortcutHandler(handleInFrame)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
setAppShortcutHandler(null)
|
||||
}
|
||||
}, [isMobile, togglePanel, focusComposer])
|
||||
|
||||
const openFile = () => fileInputRef.current?.click()
|
||||
const dismissHero = () => {
|
||||
setHeroDismissed(true)
|
||||
setPanelOpen(true)
|
||||
}
|
||||
|
||||
const panelVisible = !isMobile && !showHero && panelOpen
|
||||
// The panel floats on the right; the canvas ends where it begins
|
||||
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 overflow-hidden bg-canvas"
|
||||
data-testid="workspace"
|
||||
>
|
||||
{/* One canvas for both layouts: a new one would reload draw.io
|
||||
and lose its undo history */}
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<CanvasStage
|
||||
className={
|
||||
isMobile
|
||||
? cn(
|
||||
"inset-0",
|
||||
mobileView !== "canvas" && "invisible",
|
||||
)
|
||||
: "inset-y-0 left-0 transition-[right] duration-300 ease-[var(--ease-out)]"
|
||||
}
|
||||
style={isMobile ? undefined : { right: canvasRight }}
|
||||
compact={isMobile}
|
||||
hideOverlays={!isMobile && showHero}
|
||||
/>
|
||||
{isMobile ? (
|
||||
mobileView === "chat" &&
|
||||
(showHero ? (
|
||||
<div className="absolute inset-0 overflow-y-auto bg-canvas">
|
||||
<div className="flex items-center gap-2 px-4 pt-3">
|
||||
<BrandMark className="size-6" />
|
||||
<span className="mr-auto text-[13px] font-semibold tracking-[-0.01em]">
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
<IconButton
|
||||
label={dict.nav.settings}
|
||||
onClick={() => openSettings()}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
<LobbyHero
|
||||
compact
|
||||
onOpenFile={openFile}
|
||||
onDrawYourself={() => {
|
||||
setHeroDismissed(true)
|
||||
setMobileView("canvas")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ChatPanel mobile onOpenFile={openFile} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{panelVisible && (
|
||||
<ChatPanel onOpenFile={openFile} />
|
||||
)}
|
||||
|
||||
{!showHero && !panelOpen && (
|
||||
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
|
||||
<BrandMark className="mx-0.5 size-6" />
|
||||
<IconButton
|
||||
label={dict.nav.showPanel}
|
||||
shortcut={`${modKey}B`}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
data-testid="show-panel"
|
||||
>
|
||||
<PanelRightOpen />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHero && (
|
||||
<>
|
||||
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
|
||||
<div className="flex min-h-full items-center">
|
||||
<LobbyHero
|
||||
onOpenFile={openFile}
|
||||
onDrawYourself={dismissHero}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float">
|
||||
<BrandMark className="size-6" />
|
||||
<span className="pr-1 text-[13px] font-semibold tracking-[-0.01em]">
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
<IconButton
|
||||
label={dict.nav.settings}
|
||||
onClick={() => openSettings()}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && (
|
||||
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
|
||||
{(
|
||||
[
|
||||
{
|
||||
view: "canvas",
|
||||
label: dict.workspace.mobileCanvas,
|
||||
icon: <Shapes />,
|
||||
},
|
||||
{
|
||||
view: "chat",
|
||||
label: dict.workspace.mobileChat,
|
||||
icon: <MessageSquare />,
|
||||
},
|
||||
] as const
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
type="button"
|
||||
onClick={() => setMobileView(item.view)}
|
||||
aria-pressed={mobileView === item.view}
|
||||
className={cn(
|
||||
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
|
||||
mobileView === item.view &&
|
||||
"bg-accent font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{item.view === "chat" &&
|
||||
(isBusy || chatUnread) &&
|
||||
mobileView !== "chat" && (
|
||||
<span className="size-1.5 rounded-full bg-marker" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={DIAGRAM_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
setHeroDismissed(true)
|
||||
engine.openDiagramFile(file)
|
||||
}
|
||||
event.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingsDialog />
|
||||
<CompareDialog />
|
||||
<SaveDialog
|
||||
open={saveDialogOpen}
|
||||
onOpenChange={setSaveDialogOpen}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(
|
||||
filename,
|
||||
format,
|
||||
engine.langfuseSessionId,
|
||||
dict.save.savedSuccessfully,
|
||||
)
|
||||
}
|
||||
defaultFilename={exportFilename(engine.currentTitle)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+281
-226
@@ -1,28 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type {
|
||||
DrawioExportEvent,
|
||||
DrawioFrameHandle,
|
||||
} from "@/components/canvas/drawio-frame"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { withPageDefaults } from "@/lib/drawio/drawio-config"
|
||||
import {
|
||||
extractDiagramXML,
|
||||
isRealDiagram,
|
||||
validateAndFixXml,
|
||||
} from "../lib/utils"
|
||||
canReplaceDiagram,
|
||||
commitDiagram,
|
||||
previewDiagram,
|
||||
resetPreview,
|
||||
revertPreview,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import {
|
||||
BLANK_MXFILE,
|
||||
normalizeToMxfile,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||
|
||||
/**
|
||||
* How a diagram reaches the canvas:
|
||||
* - load: replace the whole document and reset undo (sessions, new chat)
|
||||
* - preview: streaming AI output, not recorded in undo history
|
||||
* - commit: final AI result, one undo step; reported to commit listeners
|
||||
* - revert: drop the streaming preview and go back to the given diagram
|
||||
*
|
||||
* preview/commit/revert go through the draw.io editor when it is reachable
|
||||
* (same origin); otherwise they fall back to a full load.
|
||||
*/
|
||||
export type LoadMode = "load" | "preview" | "commit" | "revert"
|
||||
|
||||
export interface DiagramCommit {
|
||||
beforeXml: string
|
||||
afterXml: string
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||
chartXMLRef: React.RefObject<string>
|
||||
latestSvg: string
|
||||
diagramHistory: { svg: string; xml: string }[]
|
||||
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||
handleExport: () => void
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
loadDiagram: (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
) => string | null
|
||||
// Both return the export's tag (empty when draw.io is not there yet)
|
||||
handleExport: () => string
|
||||
handleExportWithoutHistory: () => string
|
||||
// Pending exports by tag; a plain export's resolver gets the first
|
||||
// page's XML
|
||||
exportResolversRef: React.RefObject<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>
|
||||
drawioRef: React.RefObject<DrawioFrameHandle | null>
|
||||
handleDiagramExport: (data: DrawioExportEvent) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
@@ -32,40 +72,52 @@ interface DiagramContextType {
|
||||
successMessage?: string,
|
||||
) => void
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
getVersionSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
showSaveDialog: boolean
|
||||
setShowSaveDialog: (show: boolean) => void
|
||||
/** Register the handler told about every committed AI change */
|
||||
setCommitHandler: (
|
||||
handler: ((commit: DiagramCommit) => void) | null,
|
||||
) => void
|
||||
/** A new user turn starts: forget the streaming base */
|
||||
startTurn: () => void
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
// Every export carries a tag in the request's `message` field. draw.io
|
||||
// echoes the request back in the export event, so each result reaches its
|
||||
// own caller. Tags end in a request number, so a late result never answers
|
||||
// a newer request.
|
||||
type ExportTag = "thumbnail" | "validation" | "version"
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
const [diagramHistory, setDiagramHistory] = useState<
|
||||
{ svg: string; xml: string }[]
|
||||
>([])
|
||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Resolver for PNG export (used for VLM validation)
|
||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
||||
// Pending exports, keyed by their export tag
|
||||
const exportResolversRef = useRef<
|
||||
Record<string, (data: string, xml?: string) => void>
|
||||
>({})
|
||||
const exportSeqRef = useRef(0)
|
||||
// Track latest chartXML for restoration after remount
|
||||
const chartXMLRef = useRef<string>("")
|
||||
// Diagram before the current AI change started streaming
|
||||
const turnBaseRef = useRef<string | null>(null)
|
||||
const commitHandlerRef = useRef<((commit: DiagramCommit) => void) | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const onDrawioLoad = () => {
|
||||
// Only set ready state once to prevent infinite loops
|
||||
if (hasCalledOnLoadRef.current) return
|
||||
hasCalledOnLoadRef.current = true
|
||||
setIsDrawioReady(true)
|
||||
// Restore diagram after remount (e.g., theme/UI change)
|
||||
// Restore diagram after remount (e.g., language or theme change)
|
||||
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||
}
|
||||
@@ -76,105 +128,89 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
setIsDrawioReady(false)
|
||||
}
|
||||
|
||||
// Keep chartXMLRef in sync with state for restoration after remount
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
// Update chartXML and its ref together, so callbacks that read the ref
|
||||
// (export handler, autosave) see the new value right away
|
||||
const updateChartXML = (xml: string) => {
|
||||
chartXMLRef.current = xml
|
||||
setChartXML(xml)
|
||||
}
|
||||
|
||||
// The chat's export of the current diagram (onFetchChart)
|
||||
const handleExport = () => {
|
||||
if (drawioRef.current) {
|
||||
// Mark that this export should be saved to history
|
||||
expectHistoryExportRef.current = true
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
if (!drawioRef.current) return ""
|
||||
const tag = `fetch-${++exportSeqRef.current}`
|
||||
drawioRef.current.exportDiagram({ format: "xmlsvg", message: tag })
|
||||
return tag
|
||||
}
|
||||
|
||||
const handleExportWithoutHistory = () => {
|
||||
if (drawioRef.current) {
|
||||
// Export without saving to history (for edit_diagram fetching current state)
|
||||
drawioRef.current.exportDiagram({
|
||||
format: "xmlsvg",
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleExportWithoutHistory = handleExport
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||
// event) and wait for that result. Resolves to null on timeout, which is
|
||||
// expected occasionally.
|
||||
// (Reads refs only, so it keeps one identity)
|
||||
const requestTaggedExport = useCallback(
|
||||
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const id = `${tag}-${++exportSeqRef.current}`
|
||||
const finish = (value: string | null) => {
|
||||
clearTimeout(timer)
|
||||
delete exportResolversRef.current[id]
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
exportResolversRef.current[id] = finish
|
||||
drawioRef.current?.exportDiagram({ format, message: id })
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
||||
// renders this provider again (latestSvg), which would otherwise start
|
||||
// the next save
|
||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXML)) return null
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
try {
|
||||
const svgData = await Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
resolverRef.current = resolve
|
||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
||||
),
|
||||
])
|
||||
|
||||
// Update latestSvg so it's available for future saves
|
||||
if (svgData?.includes("<svg")) {
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
// xmlsvg exports return an SVG data URL
|
||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||
if (svgData?.startsWith("data:image/svg")) {
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
}, [requestTaggedExport])
|
||||
|
||||
// Plain SVG (no embedded diagram data) for version cards
|
||||
const getVersionSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
const svgData = await requestTaggedExport("version", "svg", 4000)
|
||||
return svgData?.startsWith("data:image/svg") ? svgData : null
|
||||
}
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
const captureValidationPng = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXML)) return null
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
try {
|
||||
const pngData = await Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
pngResolverRef.current = resolve
|
||||
drawioRef.current?.exportDiagram({ format: "png" })
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("PNG export timeout")),
|
||||
5000,
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
// PNG data should be a base64 data URL
|
||||
if (pngData?.startsWith("data:image/png")) {
|
||||
return pngData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
}
|
||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
mode: LoadMode = "load",
|
||||
meta?: { toolCallId?: string },
|
||||
): string | null => {
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||
// tool handlers check model XML strictly before it gets here.
|
||||
if (!skipValidation) {
|
||||
const validation = validateAndFixXml(chart)
|
||||
const validation = validateAndFixXml(chart, { strict: false })
|
||||
if (!validation.valid) {
|
||||
console.warn(
|
||||
"[loadDiagram] Validation error:",
|
||||
@@ -192,83 +228,92 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
setChartXML(xmlToLoad)
|
||||
xmlToLoad = withPageDefaults(xmlToLoad)
|
||||
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
xml: xmlToLoad,
|
||||
const previousXml = chartXMLRef.current
|
||||
if (mode === "preview" && turnBaseRef.current === null) {
|
||||
turnBaseRef.current = previousXml
|
||||
}
|
||||
const beforeXml = turnBaseRef.current ?? previousXml
|
||||
if (mode !== "preview") turnBaseRef.current = null
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
updateChartXML(xmlToLoad)
|
||||
|
||||
let applied = false
|
||||
if (mode !== "load" && canReplaceDiagram(xmlToLoad)) {
|
||||
try {
|
||||
if (mode === "preview") previewDiagram(xmlToLoad)
|
||||
else if (mode === "commit") commitDiagram(xmlToLoad)
|
||||
else revertPreview(xmlToLoad)
|
||||
applied = true
|
||||
} catch (error) {
|
||||
console.warn("[loadDiagram] Editor update failed:", error)
|
||||
}
|
||||
}
|
||||
if (!applied) {
|
||||
// A full load replaces any preview, and its base is stale now
|
||||
resetPreview()
|
||||
drawioRef.current?.load({ xml: xmlToLoad })
|
||||
}
|
||||
|
||||
if (mode === "commit") {
|
||||
commitHandlerRef.current?.({
|
||||
beforeXml,
|
||||
afterXml: xmlToLoad,
|
||||
toolCallId: meta?.toolCallId,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
pngResolverRef.current = null
|
||||
const startTurn = () => {
|
||||
turnBaseRef.current = null
|
||||
resetPreview()
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||
// Thumbnail, version, validation PNG and file save exports go only
|
||||
// to their own caller
|
||||
const tag = data.message?.message
|
||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||
const MAX_HISTORY_SIZE = 20
|
||||
if (expectHistoryExportRef.current) {
|
||||
setDiagramHistory((prev) => {
|
||||
const newHistory = [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
},
|
||||
]
|
||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||
return newHistory.slice(-MAX_HISTORY_SIZE)
|
||||
})
|
||||
expectHistoryExportRef.current = false
|
||||
}
|
||||
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(extractedXML)
|
||||
resolverRef.current = null
|
||||
// The chat's own export (onFetchChart), not another one in flight
|
||||
const resolve =
|
||||
tag !== undefined ? exportResolversRef.current[tag] : undefined
|
||||
if (resolve) {
|
||||
delete exportResolversRef.current[tag as string]
|
||||
resolve(extractedXML)
|
||||
}
|
||||
}
|
||||
|
||||
// The frame registers this callback once per iframe mount, so it must
|
||||
// read refs: state captured in its closure would stay stale after a remount
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||
// Don't overwrite a pending restore - if we have a real diagram but
|
||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
return
|
||||
}
|
||||
setChartXML(data.xml)
|
||||
updateChartXML(data.xml)
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||
loadDiagram(emptyDiagram, true)
|
||||
loadDiagram(BLANK_MXFILE, true)
|
||||
setLatestSvg("")
|
||||
setDiagramHistory([])
|
||||
}
|
||||
|
||||
const saveDiagramToFile = (
|
||||
@@ -286,81 +331,85 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const drawioFormat =
|
||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
// Each save has its own tag, so two at once never swap results
|
||||
const tag = `save-${++exportSeqRef.current}`
|
||||
exportResolversRef.current[tag] = (
|
||||
exportData: string,
|
||||
fullDiagramXML?: string,
|
||||
) => {
|
||||
delete exportResolversRef.current[tag]
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
}
|
||||
fileContent = xmlContent
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
if (format === "drawio") {
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
fileContent =
|
||||
normalizeToMxfile(xml, {
|
||||
pageId: "page-1",
|
||||
pageName: "Page-1",
|
||||
}) ?? xml
|
||||
mimeType = "application/xml"
|
||||
extension = ".drawio"
|
||||
} else if (format === "png") {
|
||||
// PNG data comes as base64 data URL
|
||||
fileContent = exportData
|
||||
mimeType = "image/png"
|
||||
extension = ".png"
|
||||
} else if (format === "xmlsvg") {
|
||||
// Editable SVG: pass data URL directly (like PNG)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".drawio.svg"
|
||||
} else {
|
||||
// SVG format (view-only)
|
||||
fileContent = exportData
|
||||
mimeType = "image/svg+xml"
|
||||
extension = ".svg"
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId)
|
||||
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
// Handle download
|
||||
let url: string
|
||||
if (
|
||||
typeof fileContent === "string" &&
|
||||
fileContent.startsWith("data:")
|
||||
) {
|
||||
// Already a data URL (PNG)
|
||||
url = fileContent
|
||||
} else {
|
||||
const blob = new Blob([fileContent], { type: mimeType })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${filename}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
position: "bottom-left",
|
||||
duration: 2500,
|
||||
})
|
||||
}
|
||||
// Show success toast after download is initiated
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, { duration: 2500 })
|
||||
}
|
||||
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
},
|
||||
format,
|
||||
// Delay URL revocation to ensure download completes
|
||||
if (!url.startsWith("data:")) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||
drawioRef.current.exportDiagram({
|
||||
format: drawioFormat,
|
||||
message: tag,
|
||||
})
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
@@ -380,29 +429,35 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const setCommitHandler = (
|
||||
handler: ((commit: DiagramCommit) => void) | null,
|
||||
) => {
|
||||
commitHandlerRef.current = handler
|
||||
}
|
||||
|
||||
return (
|
||||
<DiagramContext.Provider
|
||||
value={{
|
||||
chartXML,
|
||||
chartXMLRef,
|
||||
latestSvg,
|
||||
diagramHistory,
|
||||
setDiagramHistory,
|
||||
loadDiagram,
|
||||
handleExport,
|
||||
handleExportWithoutHistory,
|
||||
resolverRef,
|
||||
exportResolversRef,
|
||||
drawioRef,
|
||||
handleDiagramExport,
|
||||
handleDiagramAutoSave,
|
||||
clearDiagram,
|
||||
saveDiagramToFile,
|
||||
getThumbnailSvg,
|
||||
getVersionSvg,
|
||||
captureValidationPng,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
setCommitHandler,
|
||||
startTurn,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+4
-8
@@ -1,14 +1,11 @@
|
||||
services:
|
||||
drawio:
|
||||
image: jgraph/drawio:latest
|
||||
ports: ["8080:8080"]
|
||||
next-ai-draw-io:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
|
||||
# Uncomment below for subdirectory deployment
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
# draw.io is bundled into the image (public/drawio), no separate container needed
|
||||
# Uncomment below for subdirectory deployment
|
||||
# args:
|
||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||
ports: ["3000:3000"]
|
||||
env_file: .env
|
||||
volumes:
|
||||
@@ -17,4 +14,3 @@ services:
|
||||
# environment:
|
||||
# # For subdirectory deployment, uncomment and set your path:
|
||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||
depends_on: [drawio]
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
|
||||
|
||||
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
**关键点**: 应用现在自带一份 draw.io,从 `/drawio` 提供,浏览器不再需要访问 `embed.diagrams.net`。如果仍然看到这个错误,说明镜像是用 `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(旧版 Dockerfile 的默认值)构建的,去掉这个构建参数重新构建即可。详见[离线部署](./offline-deployment.md)。
|
||||
|
||||
**解决方案**: 必须在构建时通过 `args` 传入:
|
||||
`NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
|
||||
|
||||
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
+10
-2
@@ -93,7 +93,8 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
|
||||
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
|
||||
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
|
||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
- **版本与撤销**:AI 每改一次图,聊天里就多一张带缩略图的版本卡片,可以和画布对比、恢复,也可以撤销这次修改;在画布上按 Ctrl+Z 也能一步撤回 AI 的修改。AI 刚改过的图形会在画布上标出来
|
||||
- **针对选中图形提问**:在画布上选中图形,让 AI 只改这几个
|
||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
@@ -124,6 +125,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
|
||||
图表会实时显示在浏览器中!
|
||||
|
||||
MCP服务器包含网页版的大部分画图功能:
|
||||
|
||||
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
|
||||
- 截图工具,AI可以查看画好的图并自行修正
|
||||
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
|
||||
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
|
||||
|
||||
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
|
||||
|
||||
## 快速开始
|
||||
@@ -236,7 +244,7 @@ npm run dev
|
||||
|
||||
- **Next.js**:用于前端框架和路由
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
|
||||
- **react-drawio**:用于图表表示和操作
|
||||
- **draw.io**:编辑器使用打包在 `public/drawio` 里的副本(由 `npm run dev` / `npm run build` 自动下载),应用可以直接控制它
|
||||
|
||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
|
||||
@@ -149,9 +151,11 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
|
||||
|
||||
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
|
||||
|
||||
### OpenRouter
|
||||
@@ -190,7 +194,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||
可选的自定义 URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# 离线部署
|
||||
|
||||
通过自托管 draw.io 来替代 `embed.diagrams.net`,从而离线部署 Next AI Draw.io。
|
||||
|
||||
**注意:** `NEXT_PUBLIC_DRAWIO_BASE_URL` 是一个**构建时**变量。修改它需要重新构建 Docker 镜像。
|
||||
Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)会把 draw.io 的发布包下载到 `public/drawio`,只下载一次,应用从 `/drawio` 提供它。使用时浏览器不会访问 `embed.diagrams.net`,所以只要构建时能联网,应用就能在离线网络或内网里使用。
|
||||
|
||||
## Docker Compose 设置
|
||||
|
||||
1. 克隆仓库并在 `.env` 文件中定义 API 密钥。
|
||||
2. 创建 `docker-compose.yml`:
|
||||
2. 运行 `docker compose up -d`(使用仓库里的 `docker-compose.yml`)。
|
||||
3. 打开 `http://localhost:3000`。
|
||||
|
||||
不需要单独的 draw.io 容器。
|
||||
|
||||
## 构建机器不能联网时
|
||||
|
||||
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
||||
|
||||
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
||||
|
||||
## 使用单独的 draw.io 服务器(可选)
|
||||
|
||||
仍然可以用构建时变量 `NEXT_PUBLIC_DRAWIO_BASE_URL` 让应用使用别处的 draw.io,比如 `jgraph/drawio` 镜像:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. 运行 `docker compose up -d` 并打开 `http://localhost:3000`。
|
||||
|
||||
## 配置与重要警告
|
||||
使用外部 draw.io 时,应用改用 draw.io 自己的工具栏,以下功能不可用:标出 AI 改了哪些图形、针对选中的图形提问、应用自己的画布工具栏、用 Ctrl+Z 撤回 AI 的修改。浏览器不允许页面控制来自其他域名的编辑器。
|
||||
|
||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` 必须是用户浏览器可访问的地址。**
|
||||
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**Problem**: Intranet environment shows "Cannot find server IP address for embed.diagrams.net"
|
||||
|
||||
**Key Point**: `NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
||||
**Key Point**: The app now ships with its own copy of draw.io, served from `/drawio`, so the browser no longer needs `embed.diagrams.net`. If you still see this error, the image was built with `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net` (the old Dockerfile default). Rebuild without that build argument. See [Offline Deployment](./offline-deployment.md).
|
||||
|
||||
**Solution**: Must pass via `args` at build time:
|
||||
`NEXT_PUBLIC_*` environment variables are **build-time** variables, they get bundled into JS code. **Runtime settings don't work!**
|
||||
|
||||
**To keep a separate draw.io server instead**, pass it via `args` at build time:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
@@ -61,6 +61,8 @@ Optional custom endpoint (for OpenAI-compatible services):
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
LM Studio and other local OpenAI-compatible servers work the same way: set the base URL to `http://localhost:1234/v1` (LM Studio's default port) and use any non-empty API key, such as `lm-studio`. This works both in the environment variables above and in the model settings.
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
|
||||
@@ -164,9 +166,11 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
|
||||
|
||||
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
||||
|
||||
### OpenRouter
|
||||
@@ -192,7 +196,7 @@ AI_MODEL=llama3.2
|
||||
Optional custom URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# Offline Deployment
|
||||
|
||||
Deploy Next AI Draw.io offline by self-hosting draw.io to replace `embed.diagrams.net`.
|
||||
|
||||
**Note:** `NEXT_PUBLIC_DRAWIO_BASE_URL` is a **build-time** variable. Changing it requires rebuilding the Docker image.
|
||||
Next AI Draw.io ships with its own copy of draw.io. `npm run build` (and the Docker build) downloads the draw.io release into `public/drawio` once, and the app serves it from `/drawio`. In use, the browser never contacts `embed.diagrams.net`, so the app works on an offline or intranet network as long as the build had internet access.
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
1. Clone the repository and define API keys in `.env`.
|
||||
2. Create `docker-compose.yml`:
|
||||
2. Run `docker compose up -d` (it uses the `docker-compose.yml` in the repository).
|
||||
3. Open `http://localhost:3000`.
|
||||
|
||||
No separate draw.io container is needed.
|
||||
|
||||
## Building Without Internet Access
|
||||
|
||||
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
|
||||
|
||||
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||
- Or build the image on a machine with internet access and transfer it to the offline network.
|
||||
|
||||
## Using a Separate draw.io Server (Optional)
|
||||
|
||||
You can still point the app at another draw.io, such as the `jgraph/drawio` image, with the build-time variable `NEXT_PUBLIC_DRAWIO_BASE_URL`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. Run `docker compose up -d` and open `http://localhost:3000`.
|
||||
|
||||
## Configuration & Critical Warning
|
||||
With an external draw.io the app uses draw.io's own toolbar, and these features are off: highlighting what the AI changed, asking about selected shapes, the app's canvas toolbar, and undoing AI changes with Ctrl+Z. Browsers do not let a page control an editor served from a different origin.
|
||||
|
||||
**The `NEXT_PUBLIC_DRAWIO_BASE_URL` must be accessible from the user's browser.**
|
||||
|
||||
@@ -36,4 +46,3 @@ services:
|
||||
| Remote/Server | `http://YOUR_SERVER_IP:8080` |
|
||||
|
||||
**Do NOT use** internal Docker aliases like `http://drawio:8080`; the browser cannot resolve them.
|
||||
|
||||
|
||||
+4
-2
@@ -18,9 +18,11 @@
|
||||
|
||||
**問題**: イントラネット環境で「embed.diagrams.netのサーバーIPアドレスが見つかりません」と表示される
|
||||
|
||||
**重要**: `NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
||||
**重要**: アプリには draw.io のコピーが同梱され、`/drawio` から配信されるため、ブラウザは `embed.diagrams.net` にアクセスしなくなりました。それでもこのエラーが出る場合は、イメージが `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(以前の Dockerfile の既定値)でビルドされています。このビルド引数を外して再ビルドしてください。詳しくは[オフラインデプロイ](./offline-deployment.md)を参照してください。
|
||||
|
||||
**解決策**: ビルド時に `args` で渡す必要があります:
|
||||
`NEXT_PUBLIC_*` 環境変数は**ビルド時**変数であり、JSコードにバンドルされます。**実行時の設定は無効です!**
|
||||
|
||||
**別の draw.io サーバーを使い続ける場合**は、ビルド時に `args` で渡します:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
+10
-2
@@ -91,7 +91,8 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **画像ベースのダイアグラム複製**:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
||||
- **PDFとテキストファイルのアップロード**:PDFドキュメントやテキストファイルをアップロードして、既存のドキュメントからコンテンツを抽出し、ダイアグラムを生成
|
||||
- **AI推論プロセス表示**:サポートされているモデル(OpenAI o1/o3、Gemini、Claudeなど)のAIの思考プロセスを表示
|
||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
- **バージョンと取り消し**:AI が図を変更するたびに、サムネイル付きのバージョンカードがチャットに表示されます。キャンバスと比較したり、復元したり、その変更を取り消したりできます。キャンバスで Ctrl+Z を押しても AI の変更を一度に元に戻せます。AI が変更した図形はキャンバス上で強調表示されます
|
||||
- **選択した図形について依頼**:キャンバスで図形を選択し、その図形だけを AI に直してもらえます
|
||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
@@ -122,6 +123,13 @@ Claudeにダイアグラムの作成を依頼:
|
||||
|
||||
ダイアグラムがリアルタイムでブラウザに表示されます!
|
||||
|
||||
MCPサーバーには、Webアプリの主な作図機能が含まれています:
|
||||
|
||||
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
|
||||
- スクリーンショットツール:AIが描画結果を確認して修正できます
|
||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
|
||||
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
|
||||
|
||||
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
|
||||
|
||||
## はじめに
|
||||
@@ -235,7 +243,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
||||
|
||||
- **Next.js**:フロントエンドフレームワークとルーティング
|
||||
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
||||
- **react-drawio**:ダイアグラムの表現と操作
|
||||
- **draw.io**:エディターは `public/drawio` に同梱したコピー(`npm run dev` / `npm run build` で自動ダウンロード)で動作し、アプリから直接操作できます
|
||||
|
||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ AI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
||||
```
|
||||
|
||||
LM Studio などのローカルな OpenAI 互換サーバーも同じ方法で使えます。ベース URL を `http://localhost:1234/v1`(LM Studio の既定ポート)に設定し、API キーには `lm-studio` など空でない任意の値を入力してください。上の環境変数でも、モデル設定画面でも設定できます。
|
||||
|
||||
### AIHubMix
|
||||
|
||||
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
|
||||
@@ -149,9 +151,11 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
|
||||
AWS_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
||||
AI_MODEL=global.anthropic.claude-sonnet-5-5
|
||||
```
|
||||
|
||||
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
|
||||
|
||||
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
|
||||
|
||||
### OpenRouter
|
||||
@@ -177,7 +181,7 @@ AI_MODEL=llama3.2
|
||||
任意のカスタム URL:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_BASE_URL=http://localhost:11434/api
|
||||
```
|
||||
|
||||
### ModelScope
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
# オフラインデプロイ
|
||||
|
||||
`embed.diagrams.net` の代わりに draw.io をセルフホストすることで、Next AI Draw.io をオフライン環境にデプロイできます。
|
||||
|
||||
**注:** `NEXT_PUBLIC_DRAWIO_BASE_URL` は**ビルド時**の変数です。これを変更する場合は、Docker イメージの再ビルドが必要です。
|
||||
Next AI Draw.io には draw.io のコピーが同梱されています。`npm run build`(Docker ビルドを含む)が draw.io のリリースを一度だけ `public/drawio` にダウンロードし、アプリは `/drawio` からそれを配信します。利用中のブラウザは `embed.diagrams.net` にアクセスしないため、ビルド時にインターネットに接続できれば、オフライン環境やイントラネットでも動作します。
|
||||
|
||||
## Docker Compose のセットアップ
|
||||
|
||||
1. リポジトリをクローンし、`.env` ファイルに API キーを定義します。
|
||||
2. `docker-compose.yml` を作成します。
|
||||
2. `docker compose up -d` を実行します(リポジトリの `docker-compose.yml` を使います)。
|
||||
3. `http://localhost:3000` を開きます。
|
||||
|
||||
draw.io 用の別コンテナは不要です。
|
||||
|
||||
## ビルド環境がインターネットに接続できない場合
|
||||
|
||||
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
||||
|
||||
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
||||
|
||||
## 別の draw.io サーバーを使う場合(任意)
|
||||
|
||||
ビルド時の変数 `NEXT_PUBLIC_DRAWIO_BASE_URL` で、`jgraph/drawio` イメージなど別の draw.io を使うこともできます。
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -24,9 +36,7 @@ services:
|
||||
depends_on: [drawio]
|
||||
```
|
||||
|
||||
3. `docker compose up -d` を実行し、`http://localhost:3000` にアクセスします。
|
||||
|
||||
## 設定と重要な警告
|
||||
外部の draw.io を使うと、アプリは draw.io 自身のツールバーを使い、次の機能は使えなくなります:AI が変更した図形の強調表示、選択した図形についての依頼、アプリのキャンバスツールバー、Ctrl+Z による AI の変更の取り消し。ブラウザは、別のオリジンから配信されたエディターをページから操作することを許可しないためです。
|
||||
|
||||
**`NEXT_PUBLIC_DRAWIO_BASE_URL` は、ユーザーのブラウザからアクセスできる必要があります。**
|
||||
|
||||
@@ -35,4 +45,4 @@ services:
|
||||
| ローカルホスト | `http://localhost:8080` |
|
||||
| リモート/サーバー | `http://YOUR_SERVER_IP:8080` |
|
||||
|
||||
**`http://drawio:8080` のような Docker 内部のエイリアスは絶対に使用しないでください。** ブラウザはこれらを名前解決できません。
|
||||
**Docker の内部エイリアス**(例:`http://drawio:8080`)は**使用しないでください**。ブラウザはそれらを解決できません。
|
||||
|
||||
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
|
||||
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
|
||||
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
|
||||
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
|
||||
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
|
||||
|
||||
## Networking & Infrastructure
|
||||
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
|---------|--------|--------|-------------|------|
|
||||
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
|
||||
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
|
||||
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
|
||||
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
|
||||
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
|
||||
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
|
||||
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
|
||||
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
|
||||
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
|
||||
|
||||
## General Diagrams
|
||||
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
|
||||
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
|
||||
|
||||
## Enterprise Software
|
||||
|
||||
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|
||||
| Library | Shapes | Prefix | Description | File |
|
||||
|---------|--------|--------|-------------|------|
|
||||
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
|
||||
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
|
||||
|
||||
**Total: 33 libraries, 4,281 shapes**
|
||||
**Total: 30 libraries, 4,184 shapes**
|
||||
|
||||
@@ -67,41 +67,64 @@ const MODEL_ALIASES: Record<string, string> = {
|
||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized response with CORS headers
|
||||
* Create standardized JSON response
|
||||
*/
|
||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...CORS_HEADERS,
|
||||
...extraHeaders,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OPTIONS request for CORS preflight
|
||||
*/
|
||||
function handleOptionsRequest(): Response {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
})
|
||||
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
||||
// function, so no CORS headers are sent: other sites' pages can't call it
|
||||
// from a browser and spend the deployment's Edge AI quota.
|
||||
// Same rule as lib/access-code.ts, but reading the edge function's env.
|
||||
// No codes configured (or env unavailable) means no check.
|
||||
function hasValidAccessCode(request: Request, env: any): boolean {
|
||||
const accessCodes: string[] =
|
||||
env?.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code: string) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length === 0) return true
|
||||
const accessCode = request.headers.get("x-access-code")
|
||||
return !!accessCode && accessCodes.includes(accessCode)
|
||||
}
|
||||
|
||||
export async function onRequest({ request, env: _env }: any) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return handleOptionsRequest()
|
||||
export async function onRequest({ request, env }: any) {
|
||||
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||
// preflight, which fails without CORS headers. Only the type before any
|
||||
// parameters counts: "text/plain; x=application/json" needs none.
|
||||
const mediaType = (request.headers.get("content-type") ?? "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (request.method !== "POST" || mediaType !== "application/json") {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
message: "Expected a POST request with a JSON body",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasValidAccessCode(request, env)) {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid or missing access code",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
request.headers.delete("accept-encoding")
|
||||
@@ -153,7 +176,7 @@ export async function onRequest({ request, env: _env }: any) {
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
429,
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,7 +239,6 @@ export async function onRequest({ request, env: _env }: any) {
|
||||
"Cache-Control": "no-cache, no-store, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
Connection: "keep-alive",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
Vendored
+9
-4
@@ -60,10 +60,6 @@ declare global {
|
||||
maximize: () => void
|
||||
/** Close the window */
|
||||
close: () => void
|
||||
/** Open file dialog and return file path */
|
||||
openFile: () => Promise<string | null>
|
||||
/** Save data to file via save dialog */
|
||||
saveFile: (data: string) => Promise<boolean>
|
||||
/** Get proxy configuration */
|
||||
getProxy: () => Promise<ProxyConfig>
|
||||
/** Set proxy configuration (saves and restarts server) */
|
||||
@@ -74,6 +70,15 @@ declare global {
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
/**
|
||||
* Call back after the server restarted on the same port (another
|
||||
* preset); returns a function that stops the calls
|
||||
*/
|
||||
onServerRestarted?: (callback: () => void) => () => void
|
||||
/** A chat was saved: open this port next launch */
|
||||
chatSaved?: () => Promise<void>
|
||||
/** The page loaded with this many chats */
|
||||
chatsLoaded?: (count: number) => Promise<void>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
|
||||
+70
-26
@@ -32,6 +32,68 @@ export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
// Number of the latest preset switch
|
||||
let lastSwitch = 0
|
||||
|
||||
/**
|
||||
* Apply a preset and restart the server so it takes effect.
|
||||
* If the restart fails, go back to the previous preset and restart again,
|
||||
* so the running server always matches the saved current preset.
|
||||
* Throws an error describing the outcome on failure.
|
||||
*/
|
||||
export async function switchPreset(
|
||||
id: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
throw new Error("Preset not found")
|
||||
}
|
||||
const switchNumber = ++lastSwitch
|
||||
rebuildAppMenu()
|
||||
|
||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||
if (!app.isPackaged) {
|
||||
return env
|
||||
}
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
return env
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// A newer switch started meanwhile (also of this same preset), or
|
||||
// the preset was deleted: its own restart follows, and undoing
|
||||
// would lose that choice
|
||||
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
} catch (retryError) {
|
||||
console.error("Failed to restart server again:", retryError)
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template with translations
|
||||
*/
|
||||
@@ -192,32 +254,14 @@ function buildConfigMenu(
|
||||
type: "radio",
|
||||
checked: preset.id === currentPresetId,
|
||||
click: async () => {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(preset.id)
|
||||
|
||||
if (env) {
|
||||
try {
|
||||
await restartNextServer()
|
||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (previousPresetId) {
|
||||
applyPresetToEnv(previousPresetId)
|
||||
} else {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await switchPreset(preset.id)
|
||||
} catch (error) {
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
+153
-70
@@ -1,5 +1,11 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, safeStorage } from "electron"
|
||||
|
||||
@@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false
|
||||
* Warns if encryption is not available (API key stored in plaintext)
|
||||
*/
|
||||
function encryptValue(value: string): string {
|
||||
if (!value) {
|
||||
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||
// instead of wrapping it in a second layer of encryption
|
||||
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -61,6 +69,7 @@ function encryptValue(value: string): string {
|
||||
/**
|
||||
* Decrypt a sensitive value using safeStorage
|
||||
* Returns the original value if it's not encrypted or decryption fails
|
||||
* (so saving writes the stored ciphertext back unchanged)
|
||||
*/
|
||||
function decryptValue(value: string): string {
|
||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
@@ -150,6 +159,10 @@ function getConfigFilePath(): string {
|
||||
return path.join(userDataPath, CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
// The presets file exists but the last read failed: a save now would
|
||||
// replace the user's presets with the empty list that read returned
|
||||
let presetsUnreadable = false
|
||||
|
||||
/**
|
||||
* Load presets from the config file
|
||||
* Decrypts sensitive fields automatically
|
||||
@@ -158,6 +171,25 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
const configPath = getConfigFilePath()
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
// Nothing left that a save could overwrite
|
||||
presetsUnreadable = false
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
presets: [],
|
||||
userLocale: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(configPath, "utf-8")
|
||||
presetsUnreadable = false
|
||||
} catch (error) {
|
||||
// Often only for now (on Windows an antivirus scanner can hold the
|
||||
// file): keep the file, and refuse saves based on this empty list
|
||||
console.error("Failed to read config presets:", error)
|
||||
presetsUnreadable = true
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
@@ -167,7 +199,6 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const data = JSON.parse(content) as ConfigPresetsFile
|
||||
|
||||
// Decrypt sensitive fields in each preset
|
||||
@@ -179,6 +210,17 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to load config presets:", error)
|
||||
// Move the unreadable file aside so the next save can't overwrite
|
||||
// the user's presets with an empty list
|
||||
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
||||
try {
|
||||
renameSync(configPath, backupPath)
|
||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||
} catch (renameError) {
|
||||
// Still there: refuse saves that would overwrite it
|
||||
console.error("Failed to back up config presets:", renameError)
|
||||
presetsUnreadable = true
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
@@ -193,6 +235,11 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
* Encrypts sensitive fields automatically
|
||||
*/
|
||||
export function savePresets(data: ConfigPresetsFile): void {
|
||||
if (presetsUnreadable) {
|
||||
throw new Error(
|
||||
"The presets file could not be read, so it was not overwritten. Please try again.",
|
||||
)
|
||||
}
|
||||
const configPath = getConfigFilePath()
|
||||
const userDataPath = app.getPath("userData")
|
||||
|
||||
@@ -211,7 +258,11 @@ export function savePresets(data: ConfigPresetsFile): void {
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated config file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config presets:", error)
|
||||
throw error
|
||||
@@ -307,9 +358,10 @@ export function deletePreset(id: string): boolean {
|
||||
|
||||
data.presets.splice(index, 1)
|
||||
|
||||
// Clear current preset if it was deleted
|
||||
// Clear current preset (and its env vars) if it was deleted
|
||||
if (data.currentPresetId === id) {
|
||||
data.currentPresetId = null
|
||||
setPresetEnv(null)
|
||||
}
|
||||
|
||||
savePresets(data)
|
||||
@@ -322,13 +374,15 @@ export function deletePreset(id: string): boolean {
|
||||
export function setCurrentPreset(id: string | null): boolean {
|
||||
const data = loadPresets()
|
||||
|
||||
let preset: ConfigPreset | null = null
|
||||
if (id !== null) {
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
preset = data.presets.find((p) => p.id === id) || null
|
||||
if (!preset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setPresetEnv(preset)
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
return true
|
||||
@@ -365,78 +419,23 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset environment variables to the current process
|
||||
* Returns the environment variables that were applied
|
||||
*/
|
||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||
const data = loadPresets()
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
|
||||
if (!preset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appliedEnv: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
||||
if (providerApiKey) {
|
||||
process.env[providerApiKey] = value
|
||||
appliedEnv[providerApiKey] = value
|
||||
}
|
||||
}
|
||||
// Map generic AI_BASE_URL to provider-specific key
|
||||
else if (
|
||||
key === "AI_BASE_URL" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
) {
|
||||
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
|
||||
if (providerBaseUrl) {
|
||||
process.env[providerBaseUrl] = value
|
||||
appliedEnv[providerBaseUrl] = value
|
||||
}
|
||||
}
|
||||
// Apply other env vars directly
|
||||
else {
|
||||
process.env[key] = value
|
||||
appliedEnv[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set as current preset
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
|
||||
return appliedEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables from current preset
|
||||
* Map a preset's config to environment variables
|
||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||
*/
|
||||
export function getCurrentPresetEnv(): Record<string, string> {
|
||||
const preset = getCurrentPreset()
|
||||
if (!preset) {
|
||||
return {}
|
||||
}
|
||||
|
||||
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// A key that could not be decrypted is useless to the server
|
||||
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
console.warn(
|
||||
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
||||
)
|
||||
}
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
if (
|
||||
else if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
@@ -466,6 +465,90 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Values that env vars had before a preset first set them
|
||||
* (from the system or .env files), and the keys the active preset set
|
||||
*/
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
let presetEnvKeys: string[] = []
|
||||
|
||||
/**
|
||||
* Replace the env vars of the previous preset with those of the given preset
|
||||
* (null leaves no preset applied). Restoring first means switching presets
|
||||
* never leaves the previous preset's base URL, model or key behind.
|
||||
*/
|
||||
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||
for (const key of presetEnvKeys) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
|
||||
const env = preset ? presetToEnv(preset) : {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
originalEnv[key] = process.env[key]
|
||||
}
|
||||
process.env[key] = value
|
||||
}
|
||||
presetEnvKeys = Object.keys(env)
|
||||
|
||||
writeDevPresetEnv(env)
|
||||
return env
|
||||
}
|
||||
|
||||
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||
|
||||
/**
|
||||
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||
* this file changes. The dev server can't decrypt the config file itself.
|
||||
*/
|
||||
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||
if (app.isPackaged) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to write dev preset env:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset environment variables to the current process
|
||||
* Returns the environment variables that were applied
|
||||
*/
|
||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||
const data = loadPresets()
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
|
||||
if (!preset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appliedEnv = setPresetEnv(preset)
|
||||
|
||||
// Set as current preset
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
|
||||
return appliedEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the saved current preset's environment variables (used at startup)
|
||||
*/
|
||||
export function applyCurrentPresetToEnv(): void {
|
||||
setPresetEnv(getCurrentPreset())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred locale from config
|
||||
* Returns undefined if not set
|
||||
|
||||
@@ -28,6 +28,25 @@ export function loadEnvFile(): void {
|
||||
console.log("No .env file found, using system environment variables")
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the quote that closes a value starting with a quote, or -1. A
|
||||
* backslash before the quote character escapes it, as in dotenv; the
|
||||
* backslash stays in the value. As in dotenv, an escaped quote with only a
|
||||
* comment or nothing after it still closes the value when no other quote
|
||||
* does ("C:\dir\" keeps its trailing backslash).
|
||||
*/
|
||||
function findClosingQuote(value: string): number {
|
||||
const quote = value[0]
|
||||
let lastEscaped = -1
|
||||
for (let i = 1; i < value.length; i++) {
|
||||
if (value[i] === "\\" && value[i + 1] === quote) {
|
||||
i++
|
||||
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
|
||||
} else if (value[i] === quote) return i
|
||||
}
|
||||
return lastEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load environment variables from a file
|
||||
*/
|
||||
@@ -48,12 +67,28 @@ function loadEnvFromFile(filePath: string): void {
|
||||
const key = trimmed.slice(0, equalIndex).trim()
|
||||
let value = trimmed.slice(equalIndex + 1).trim()
|
||||
|
||||
// Remove surrounding quotes
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
closingQuote > 0 &&
|
||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
// Quoted value, then nothing or a comment: keep what is
|
||||
// inside the quotes, as dotenv reads it
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment").
|
||||
// A value quoted from start to end with quotes inside (JSON
|
||||
// with an apostrophe) loses only the outer two, as in dotenv.
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
if (
|
||||
closingQuote > 0 &&
|
||||
value.length > 1 &&
|
||||
value.endsWith(quote)
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
|
||||
+50
-20
@@ -1,12 +1,17 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { applyProxyToEnv } from "./proxy-manager"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import { createWindow, getMainWindow } from "./window-manager"
|
||||
import {
|
||||
createWindow,
|
||||
getAppUrl,
|
||||
getMainWindow,
|
||||
isAppUrl,
|
||||
} from "./window-manager"
|
||||
|
||||
// Single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
@@ -28,16 +33,14 @@ if (!gotTheLock) {
|
||||
// Apply proxy settings from saved config
|
||||
applyProxyToEnv()
|
||||
|
||||
// Apply saved preset environment variables (overrides .env)
|
||||
const presetEnv = getCurrentPresetEnv()
|
||||
for (const [key, value] of Object.entries(presetEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
let serverUrl: string | null = null
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Apply saved preset environment variables (overrides .env).
|
||||
// Must run after ready: on Windows and Linux safeStorage can't
|
||||
// decrypt the API key before that.
|
||||
applyCurrentPresetToEnv()
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
@@ -46,6 +49,7 @@ if (!gotTheLock) {
|
||||
buildAppMenu()
|
||||
|
||||
try {
|
||||
let serverUrl: string
|
||||
if (isDev) {
|
||||
// Development: use the dev server URL
|
||||
serverUrl =
|
||||
@@ -69,8 +73,9 @@ if (!gotTheLock) {
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (serverUrl) {
|
||||
createWindow(serverUrl)
|
||||
const appUrl = getAppUrl()
|
||||
if (appUrl) {
|
||||
createWindow(appUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -87,24 +92,49 @@ if (!gotTheLock) {
|
||||
stopNextServer()
|
||||
})
|
||||
|
||||
// Pages allowed inside app windows: the app server and draw.io
|
||||
const isInAppUrl = (url: string): boolean => {
|
||||
if (isAppUrl(url)) return true
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
return ["diagrams.net", "draw.io"].some(
|
||||
(domain) =>
|
||||
hostname === domain || hostname.endsWith(`.${domain}`),
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isWebUrl = (url: string): boolean =>
|
||||
url.startsWith("http://") || url.startsWith("https://")
|
||||
|
||||
// Open external links in default browser
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Allow diagrams.net iframe
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1")
|
||||
) {
|
||||
if (isInAppUrl(url)) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
// Open other links in external browser
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
if (isWebUrl(url)) {
|
||||
shell.openExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
// Clicking a plain link would otherwise replace the app page with
|
||||
// an external site that keeps the preload API. Only the page
|
||||
// itself may navigate there; draw.io stays in its frame (this event
|
||||
// is for the main frame only)
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isAppUrl(event.url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (isWebUrl(event.url)) {
|
||||
shell.openExternal(event.url)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+90
-100
@@ -1,7 +1,6 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import { rebuildAppMenu } from "./app-menu"
|
||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
type ConfigPreset,
|
||||
createPreset,
|
||||
deletePreset,
|
||||
@@ -14,12 +13,14 @@ import {
|
||||
updatePreset,
|
||||
} from "./config-manager"
|
||||
import { restartNextServer } from "./next-server"
|
||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||
import {
|
||||
applyProxyToEnv,
|
||||
getProxyConfig,
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-manager"
|
||||
import { isAppUrl } from "./window-manager"
|
||||
|
||||
/**
|
||||
* Allowed configuration keys for presets
|
||||
@@ -48,16 +49,44 @@ function sanitizePresetConfig(
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IPC handler that only answers the app's own pages
|
||||
* (the main window on the app server, or the local settings page).
|
||||
* A main window that somehow ends up on an external site still gets the
|
||||
* preload API, so its calls must be rejected here.
|
||||
*/
|
||||
function handle<Args extends unknown[]>(
|
||||
channel: string,
|
||||
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
||||
): void {
|
||||
ipcMain.handle(channel, (event, ...args) => {
|
||||
const url = event.senderFrame?.url
|
||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||
}
|
||||
return listener(event, ...(args as Args))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
// ==================== App Info ====================
|
||||
|
||||
ipcMain.handle("get-version", () => {
|
||||
handle("get-version", () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
// ==================== Where the chats are ====================
|
||||
|
||||
// The page saved a chat, or loaded without any: decides which port
|
||||
// (and so which origin's chats) the next launch opens
|
||||
handle("chat-saved", () => rememberChatPort())
|
||||
handle("chats-loaded", (_event, count: unknown) => {
|
||||
if (count === 0) noteNoChats()
|
||||
})
|
||||
|
||||
// ==================== Window Controls ====================
|
||||
|
||||
ipcMain.on("window-minimize", (event) => {
|
||||
@@ -79,84 +108,30 @@ export function registerIpcHandlers(): void {
|
||||
win?.close()
|
||||
})
|
||||
|
||||
// ==================== File Dialogs ====================
|
||||
|
||||
ipcMain.handle("dialog-open-file", async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return null
|
||||
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
const content = await fs.readFile(result.filePaths[0], "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
console.error("Failed to read file:", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return false
|
||||
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [
|
||||
{ name: "Draw.io Files", extensions: ["drawio"] },
|
||||
{ name: "XML Files", extensions: ["xml"] },
|
||||
],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fs = await import("node:fs/promises")
|
||||
try {
|
||||
await fs.writeFile(result.filePath, data, "utf-8")
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Config Presets ====================
|
||||
|
||||
ipcMain.handle("config-presets:get-all", () => {
|
||||
handle("config-presets:get-all", () => {
|
||||
return getAllPresets()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current", () => {
|
||||
handle("config-presets:get-current", () => {
|
||||
return getCurrentPreset()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current-id", () => {
|
||||
handle("config-presets:get-current-id", () => {
|
||||
return getCurrentPresetId()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
handle(
|
||||
"config-presets:save",
|
||||
(
|
||||
async (
|
||||
_event,
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||
id?: string
|
||||
},
|
||||
) => {
|
||||
// Validate preset name
|
||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
||||
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
||||
throw new Error("Invalid preset name")
|
||||
}
|
||||
|
||||
@@ -165,42 +140,48 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
if (preset.id) {
|
||||
// Update existing preset
|
||||
return updatePreset(preset.id, {
|
||||
const updated = updatePreset(preset.id, {
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
// Re-apply the active preset so the edit takes effect
|
||||
if (updated && updated.id === getCurrentPresetId()) {
|
||||
await switchPreset(updated.id)
|
||||
} else {
|
||||
rebuildAppMenu()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
// Create new preset
|
||||
return createPreset({
|
||||
const created = createPreset({
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
rebuildAppMenu()
|
||||
return created
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
||||
return deletePreset(id)
|
||||
handle("config-presets:delete", async (_event, id: string) => {
|
||||
const wasCurrent = id === getCurrentPresetId()
|
||||
// Deleting the active preset also clears its env vars
|
||||
const deleted = deletePreset(id)
|
||||
rebuildAppMenu()
|
||||
|
||||
// Restart so the server stops using the deleted preset
|
||||
if (deleted && wasCurrent && app.isPackaged) {
|
||||
await restartNextServer()
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
return { success: false, error: "Preset not found" }
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
if (isDev) {
|
||||
// In development mode, the config file change will trigger
|
||||
// the file watcher in electron-dev.mjs to restart Next.js
|
||||
// We just need to save the preset (already done in applyPresetToEnv)
|
||||
return { success: true, env, devMode: true }
|
||||
}
|
||||
|
||||
// Production mode: restart the Next.js server to apply new environment variables
|
||||
handle("config-presets:apply", async (_event, id: string) => {
|
||||
try {
|
||||
await restartNextServer()
|
||||
return { success: true, env }
|
||||
const env = await switchPreset(id)
|
||||
// In development mode, electron-dev.mjs restarts Next.js
|
||||
return app.isPackaged
|
||||
? { success: true, env }
|
||||
: { success: true, env, devMode: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -212,30 +193,39 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"config-presets:set-current",
|
||||
(_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
},
|
||||
)
|
||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
})
|
||||
|
||||
// ==================== Proxy Settings ====================
|
||||
|
||||
ipcMain.handle("get-proxy", () => {
|
||||
handle("get-proxy", () => {
|
||||
return getProxyConfig()
|
||||
})
|
||||
|
||||
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||
handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||
const isOptionalString = (value: unknown) =>
|
||||
value === undefined || typeof value === "string"
|
||||
if (
|
||||
typeof config !== "object" ||
|
||||
config === null ||
|
||||
!isOptionalString(config.httpProxy) ||
|
||||
!isOptionalString(config.httpsProxy)
|
||||
) {
|
||||
return { success: false, error: "Invalid proxy settings" }
|
||||
}
|
||||
|
||||
try {
|
||||
// Save config to file
|
||||
saveProxyConfig(config)
|
||||
saveProxyConfig({
|
||||
httpProxy: config.httpProxy,
|
||||
httpsProxy: config.httpsProxy,
|
||||
})
|
||||
|
||||
// Apply to current process environment
|
||||
applyProxyToEnv()
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
if (isDev) {
|
||||
if (!app.isPackaged) {
|
||||
// In development, env vars are already applied
|
||||
// Next.js dev server may need manual restart
|
||||
return { success: true, devMode: true }
|
||||
@@ -257,11 +247,11 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ==================== User Locale ====================
|
||||
|
||||
ipcMain.handle("get-user-locale", () => {
|
||||
handle("get-user-locale", () => {
|
||||
return getUserLocale()
|
||||
})
|
||||
|
||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
||||
handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
|
||||
@@ -7,9 +7,20 @@ import {
|
||||
getServerUrl,
|
||||
isPortAvailable,
|
||||
} from "./port-manager"
|
||||
import { setAppUrl } from "./window-manager"
|
||||
|
||||
let serverProcess: UtilityProcess | null = null
|
||||
|
||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||
// preset switches) can't leave two servers running
|
||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||
const result = serverQueue.then(task)
|
||||
serverQueue = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the standalone server resources
|
||||
* In packaged app: resources/standalone
|
||||
@@ -45,7 +56,11 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||
* Start the Next.js standalone server using Electron's utilityProcess
|
||||
* This API is designed for running Node.js code in the background
|
||||
*/
|
||||
export async function startNextServer(): Promise<string> {
|
||||
export function startNextServer(): Promise<string> {
|
||||
return runExclusive(startServer)
|
||||
}
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const resourcePath = getResourcePath()
|
||||
const serverPath = path.join(resourcePath, "server.js")
|
||||
|
||||
@@ -71,6 +86,13 @@ export async function startNextServer(): Promise<string> {
|
||||
HOSTNAME: "127.0.0.1",
|
||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||
NODE_USE_ENV_PROXY: "1",
|
||||
// The preset keys are the user's own, not a server's
|
||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||
}
|
||||
|
||||
// Keep requests to local model servers (e.g. Ollama) off the proxy
|
||||
if (!process.env.NO_PROXY && !process.env.no_proxy) {
|
||||
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
|
||||
}
|
||||
|
||||
// Set cache directory to a writable location (user's app data folder)
|
||||
@@ -96,23 +118,27 @@ export async function startNextServer(): Promise<string> {
|
||||
|
||||
// Use Electron's utilityProcess API for running Node.js in background
|
||||
// This is the recommended way to run Node.js code in Electron
|
||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||
const proc = utilityProcess.fork(serverPath, [], {
|
||||
cwd: resourcePath,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
})
|
||||
serverProcess = proc
|
||||
|
||||
serverProcess.stdout?.on("data", (data) => {
|
||||
proc.stdout?.on("data", (data) => {
|
||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.stderr?.on("data", (data) => {
|
||||
proc.stderr?.on("data", (data) => {
|
||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
proc.on("exit", (code) => {
|
||||
console.log(`Next.js server exited with code ${code}`)
|
||||
serverProcess = null
|
||||
// An old server can exit after a new one started; keep the new one
|
||||
if (serverProcess === proc) {
|
||||
serverProcess = null
|
||||
}
|
||||
})
|
||||
|
||||
const url = getServerUrl()
|
||||
@@ -126,39 +152,36 @@ export async function startNextServer(): Promise<string> {
|
||||
* Stop the Next.js server process and wait for it to exit
|
||||
*/
|
||||
export async function stopNextServer(): Promise<void> {
|
||||
if (serverProcess) {
|
||||
console.log("Stopping Next.js server...")
|
||||
const proc = serverProcess
|
||||
if (!proc) {
|
||||
return
|
||||
}
|
||||
console.log("Stopping Next.js server...")
|
||||
serverProcess = null
|
||||
|
||||
// Create a promise that resolves when the process exits
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
const proc = serverProcess
|
||||
if (!proc) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
resolve()
|
||||
}
|
||||
|
||||
proc.once("exit", onExit)
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
proc.removeListener("exit", onExit)
|
||||
resolve()
|
||||
}, 5000)
|
||||
// Resolves true when the process exits, false after the timeout
|
||||
const waitForExit = (ms: number) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
proc.once("exit", () => resolve(true))
|
||||
setTimeout(() => resolve(false), ms)
|
||||
})
|
||||
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
proc.kill()
|
||||
|
||||
// Wait for process to exit
|
||||
await exitPromise
|
||||
|
||||
// Additional wait for OS to release port
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
// Next.js waits for open requests (e.g. a streaming reply) before it
|
||||
// exits, so force kill it if it is still running after 5 seconds
|
||||
if (!(await waitForExit(5000)) && proc.pid) {
|
||||
console.warn("Next.js server did not exit in time, force killing it")
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL")
|
||||
} catch (error) {
|
||||
console.error("Failed to force kill Next.js server:", error)
|
||||
}
|
||||
await waitForExit(2000)
|
||||
}
|
||||
|
||||
// Additional wait for OS to release port
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
||||
/**
|
||||
* Restart the Next.js server with new environment variables
|
||||
*/
|
||||
export async function restartNextServer(): Promise<string> {
|
||||
console.log("Restarting Next.js server...")
|
||||
export function restartNextServer(): Promise<string> {
|
||||
return runExclusive(async () => {
|
||||
console.log("Restarting Next.js server...")
|
||||
|
||||
// Stop the current server and wait for it to exit
|
||||
await stopNextServer()
|
||||
// Stop the current server and wait for it to exit
|
||||
await stopNextServer()
|
||||
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
|
||||
// Start the server again
|
||||
return startNextServer()
|
||||
// Start the server again, and follow it if it moved to another port
|
||||
const url = await startServer()
|
||||
setAppUrl(url)
|
||||
return url
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,69 @@ const PORT_CONFIG = {
|
||||
*/
|
||||
let allocatedPort: number | null = null
|
||||
|
||||
/**
|
||||
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
|
||||
* each origin's IndexedDB in its own folder
|
||||
*/
|
||||
function hasStoredData(port: number): boolean {
|
||||
return existsSync(
|
||||
path.join(
|
||||
app.getPath("userData"),
|
||||
"IndexedDB",
|
||||
`http_127.0.0.1_${port}.indexeddb.leveldb`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The two fixed production ports, the only ones whose origin (and so its
|
||||
// chats and settings) is the same at every launch
|
||||
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
|
||||
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
|
||||
|
||||
/** The fixed port where a chat was last saved, if known */
|
||||
function readChatPort(): number | null {
|
||||
try {
|
||||
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
|
||||
return HOME_PORTS.includes(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeChatPort(port: number): void {
|
||||
try {
|
||||
writeFileSync(chatPortFile(), JSON.stringify({ port }))
|
||||
} catch (error) {
|
||||
console.warn("Could not save the chat port:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page saved a chat: open on this port next time. Chats of the two
|
||||
* ports cannot be shown together (each origin has its own storage), so the
|
||||
* app opens where the user last worked. A launch that had to use the other
|
||||
* port and saved nothing does not move it.
|
||||
*/
|
||||
export function rememberChatPort(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (readChatPort() !== port) writeChatPort(port)
|
||||
}
|
||||
|
||||
/**
|
||||
* The page loaded without any chats. Before any chat was saved under this
|
||||
* version (no file yet), the user's chats may be on the other fixed port,
|
||||
* where an older version opened: try it first next time.
|
||||
*/
|
||||
export function noteNoChats(): void {
|
||||
const port = allocatedPort
|
||||
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
|
||||
if (existsSync(chatPortFile())) return
|
||||
const other = HOME_PORTS.find((p) => p !== port)
|
||||
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
@@ -44,7 +109,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses fixed port (13370) to preserve localStorage
|
||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
||||
* localStorage; 13370 first when only it has saved chats
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
@@ -69,21 +135,26 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, try legacy port first to preserve existing users' localStorage
|
||||
if (!isDev) {
|
||||
const legacyPort = PORT_CONFIG.legacyProduction
|
||||
if (await isPortAvailable(legacyPort)) {
|
||||
allocatedPort = legacyPort
|
||||
return legacyPort
|
||||
// In production, first the port where a chat was last saved. Without
|
||||
// one, the legacy port first to preserve existing users' data, unless
|
||||
// only the new port has data: their app started on 13370 while Windows
|
||||
// reserved 61337, and 61337 being free now would hide it
|
||||
const chatPort = isDev ? null : readChatPort()
|
||||
const candidates = isDev
|
||||
? [preferredPort]
|
||||
: chatPort !== null
|
||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||
: hasStoredData(PORT_CONFIG.production) &&
|
||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||
for (const port of candidates) {
|
||||
if (await isPortAvailable(port)) {
|
||||
allocatedPort = port
|
||||
return port
|
||||
}
|
||||
}
|
||||
|
||||
// Try preferred port
|
||||
if (await isPortAvailable(preferredPort)) {
|
||||
allocatedPort = preferredPort
|
||||
return preferredPort
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Preferred port ${preferredPort} is in use, finding alternative...`,
|
||||
)
|
||||
|
||||
@@ -13,18 +13,22 @@ function getConfigPath(): string {
|
||||
|
||||
/**
|
||||
* Load proxy configuration from JSON file
|
||||
* Returns null if the user never saved proxy settings (or the file is invalid)
|
||||
*/
|
||||
export function loadProxyConfig(): ProxyConfig {
|
||||
export function loadProxyConfig(): ProxyConfig | null {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = fs.readFileSync(configPath, "utf-8")
|
||||
return JSON.parse(data) as ProxyConfig
|
||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
return data as ProxyConfig
|
||||
}
|
||||
console.error("Ignoring invalid proxy config:", data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load proxy config:", error)
|
||||
}
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig {
|
||||
export function saveProxyConfig(config: ProxyConfig): void {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
fs.renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy config:", error)
|
||||
throw error
|
||||
@@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void {
|
||||
export function applyProxyToEnv(): void {
|
||||
const config = loadProxyConfig()
|
||||
|
||||
// No saved settings: keep proxy vars inherited from the system or .env
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.httpProxy) {
|
||||
process.env.HTTP_PROXY = config.httpProxy
|
||||
process.env.http_proxy = config.httpProxy
|
||||
|
||||
@@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron"
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
// URL of the app server the main window loads
|
||||
let appUrl: string | null = null
|
||||
|
||||
/**
|
||||
* Get the icon path based on platform
|
||||
* Note: electron-builder converts icon.png during packaging,
|
||||
@@ -28,6 +31,7 @@ function getIconPath(): string | undefined {
|
||||
* Create the main application window
|
||||
*/
|
||||
export function createWindow(serverUrl: string): BrowserWindow {
|
||||
appUrl = serverUrl
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -56,7 +60,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
})
|
||||
|
||||
// Open DevTools in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
@@ -93,3 +97,39 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app server URL the main window loads
|
||||
*/
|
||||
export function getAppUrl(): string | null {
|
||||
return appUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the main window at the restarted app server (it can come up on a
|
||||
* different port). On the same port the page fetches the new preset's
|
||||
* server models instead of sending the old preset's choice; it is not
|
||||
* reloaded, which would drop unsent attachments.
|
||||
*/
|
||||
export function setAppUrl(url: string): void {
|
||||
if (url === appUrl) {
|
||||
mainWindow?.webContents.send("server-restarted")
|
||||
return
|
||||
}
|
||||
appUrl = url
|
||||
mainWindow?.loadURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL belongs to the app server (same origin)
|
||||
*/
|
||||
export function isAppUrl(url: string | undefined): boolean {
|
||||
if (!url || !appUrl) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new URL(url).origin === new URL(appUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
maximize: () => ipcRenderer.send("window-maximize"),
|
||||
close: () => ipcRenderer.send("window-close"),
|
||||
|
||||
// File operations
|
||||
openFile: () => ipcRenderer.invoke("dialog-open-file"),
|
||||
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
|
||||
|
||||
// Proxy settings
|
||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||
@@ -31,4 +27,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
|
||||
// A chat was saved, or the page loaded with this many chats: the next
|
||||
// launch opens the port where the chats are
|
||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||
|
||||
// The server restarted on the same port (another preset)
|
||||
onServerRestarted: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on("server-restarted", listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener("server-restarted", listener)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -213,6 +213,9 @@ async function savePreset() {
|
||||
}
|
||||
})
|
||||
|
||||
// closeModal() clears editingPresetId, so remember it for the toast
|
||||
const isEdit = Boolean(editingPresetId)
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true
|
||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||
@@ -220,10 +223,7 @@ async function savePreset() {
|
||||
await window.settingsAPI.savePreset(preset)
|
||||
await loadPresets()
|
||||
closeModal()
|
||||
showToast(
|
||||
editingPresetId ? "Preset updated" : "Preset created",
|
||||
"success",
|
||||
)
|
||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
||||
} catch (error) {
|
||||
console.error("Failed to save preset:", error)
|
||||
showToast("Failed to save preset", "error")
|
||||
@@ -265,8 +265,6 @@ async function applyPreset(id) {
|
||||
|
||||
const result = await window.settingsAPI.applyPreset(id)
|
||||
if (result.success) {
|
||||
currentPresetId = id
|
||||
renderPresets()
|
||||
showToast("Preset applied, server restarting...", "success")
|
||||
} else {
|
||||
showToast(result.error || "Failed to apply preset", "error")
|
||||
@@ -274,6 +272,9 @@ async function applyPreset(id) {
|
||||
} catch (error) {
|
||||
console.error("Failed to apply preset:", error)
|
||||
showToast("Failed to apply preset", "error")
|
||||
} finally {
|
||||
// Reload to show the active preset and reset the Apply button
|
||||
await loadPresets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-6
@@ -1,6 +1,6 @@
|
||||
# AI Provider Configuration
|
||||
# AI_PROVIDER: Which provider to use
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
|
||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud
|
||||
# Default: bedrock
|
||||
AI_PROVIDER=bedrock
|
||||
|
||||
@@ -11,6 +11,12 @@ AI_PROVIDER=bedrock
|
||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
|
||||
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
||||
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
|
||||
# and raise it only when they use their own API key, so this also caps cost on server keys.
|
||||
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
||||
# MAX_OUTPUT_TOKENS=64000
|
||||
|
||||
# AWS Bedrock Configuration
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||
@@ -38,7 +44,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Google Generative AI Configuration
|
||||
# GOOGLE_GENERATIVE_AI_API_KEY=...
|
||||
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
|
||||
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
|
||||
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
|
||||
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
|
||||
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
|
||||
@@ -65,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# AZURE_REASONING_SUMMARY=detailed
|
||||
|
||||
# Ollama Configuration (Local or Cloud)
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
|
||||
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434/api)
|
||||
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
|
||||
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
|
||||
|
||||
@@ -118,9 +123,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
|
||||
# Temperature (Optional)
|
||||
# Controls randomness in AI responses. Lower = more deterministic.
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
|
||||
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models).
|
||||
# Claude 4.7 and later reject it; the request is then retried without it.
|
||||
# TEMPERATURE=0
|
||||
|
||||
# Debug Logging (Optional)
|
||||
# Log the structure of the messages each chat request sends to the model
|
||||
# DEBUG_LLM_PAYLOAD=true
|
||||
|
||||
# Access Control (Optional)
|
||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
||||
|
||||
@@ -133,8 +143,11 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||
|
||||
# Draw.io Configuration (Optional)
|
||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
||||
# Use this to point to a self-hosted draw.io instance
|
||||
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
||||
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
||||
# highlighting AI changes, asking about selected shapes and the custom toolbar.
|
||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Optional: use an external draw.io instead
|
||||
# With an external draw.io the app falls back to draw.io's own toolbar and those features are off.
|
||||
|
||||
# Subdirectory Deployment (Optional)
|
||||
# For deploying to a subdirectory (e.g., https://example.com/nextaidrawio)
|
||||
@@ -153,11 +166,22 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||
# ALLOW_PRIVATE_URLS=false
|
||||
|
||||
# Behind a CDN such as Cloudflare (Optional)
|
||||
# The daily quota is counted per IP. By default the IP is the first
|
||||
# X-Forwarded-For entry, which visitors can set to anything. Name the header
|
||||
# your CDN fills with the visitor's real IP instead:
|
||||
# CLIENT_IP_HEADER=cf-connecting-ip
|
||||
# Then have the CDN add an X-Origin-Secret header with this value to every
|
||||
# request. API calls without it get 403, so nobody can skip the CDN and fake
|
||||
# the IP header above.
|
||||
# ORIGIN_SECRET=a-long-random-string
|
||||
|
||||
# Self-hosted deployment (Optional)
|
||||
# Self-hosted users may implement custom quota-management solutions,
|
||||
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
||||
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||
# It also turns off the MCP server advertisement in Settings > About.
|
||||
# NEXT_PUBLIC_SELFHOSTED=true
|
||||
|
||||
# Minimax Configuration (Optional)
|
||||
|
||||
+154
-101
@@ -1,16 +1,21 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import { useRef } from "react"
|
||||
import type { RefObject } from "react"
|
||||
import type { DiagramOperation } from "@/components/chat/types"
|
||||
import type {
|
||||
ValidationState,
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { LoadMode } from "@/contexts/diagram-context"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
// display_diagram replaces the document with this one page
|
||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
@@ -46,16 +51,29 @@ type ValidateDiagramFn = (
|
||||
) => Promise<ValidationResult>
|
||||
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
onExport: () => void
|
||||
partialXmlRef: RefObject<string>
|
||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||
// Tool calls the streaming preview must leave alone (shared with it)
|
||||
processedToolCallsRef: RefObject<Set<string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: RefObject<number>
|
||||
chartXMLRef: RefObject<string>
|
||||
onDisplayChart: (
|
||||
xml: string,
|
||||
skipValidation?: boolean,
|
||||
mode?: LoadMode,
|
||||
meta?: { toolCallId?: string },
|
||||
) => string | null
|
||||
onFetchChart: () => Promise<string>
|
||||
captureValidationPng?: () => Promise<string | null>
|
||||
validateDiagram?: ValidateDiagramFn
|
||||
enableVlmValidation?: boolean
|
||||
sessionId?: string
|
||||
// Called when a screenshot check begins; the function it returns
|
||||
// tells whether the user pressed Stop in this turn, also after the next
|
||||
// message was sent. A check that has not started then is skipped (one
|
||||
// already running is cancelled by the caller).
|
||||
watchStop?: () => () => boolean
|
||||
onValidationStateChange?: (
|
||||
toolCallId: string,
|
||||
state: ValidationState,
|
||||
@@ -72,19 +90,18 @@ interface UseDiagramToolHandlersParams {
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
processedToolCallsRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
onExport,
|
||||
captureValidationPng,
|
||||
validateDiagram,
|
||||
enableVlmValidation = true,
|
||||
sessionId,
|
||||
watchStop,
|
||||
onValidationStateChange,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
// Track validation retry count per tool call
|
||||
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Helper to update validation state
|
||||
const updateValidationState = (
|
||||
toolCallId: string,
|
||||
@@ -114,18 +131,49 @@ export function useDiagramToolHandlers({
|
||||
)
|
||||
}
|
||||
|
||||
processedToolCallsRef.current.add(toolCall.toolCallId)
|
||||
// Only display_diagram, edit_diagram and a completing append_diagram
|
||||
// put their result on the canvas. Other tools (get_shape_library,
|
||||
// which the server runs, still arrives here) leave the stored
|
||||
// originals for the preview code to undo.
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
// A truncated diagram is not drawn (append_diagram finishes it):
|
||||
// its preview is undone like a failed call's, so its original
|
||||
// stays stored
|
||||
const { xml } = (toolCall.input ?? {}) as { xml?: unknown }
|
||||
const truncated =
|
||||
typeof xml === "string" && !isMxCellXmlComplete(xml)
|
||||
await handleDisplayDiagram(
|
||||
toolCall,
|
||||
addToolOutput,
|
||||
truncated ? undefined : takeOriginals(),
|
||||
)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
await handleEditDiagram(toolCall, addToolOutput, takeOriginals())
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Stored originals belong to previews not handled yet: this call's, and
|
||||
// those of earlier calls with invalid input, which never get to the
|
||||
// handler. The first is the diagram before all of them. A call that
|
||||
// draws its result replaces those previews, so the preview code must
|
||||
// neither draw them again nor undo them later. Returns that first one.
|
||||
const takeOriginals = (): string | undefined => {
|
||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||
for (const id of editDiagramOriginalXmlRef.current.keys()) {
|
||||
processedToolCallsRef.current.add(id)
|
||||
}
|
||||
editDiagramOriginalXmlRef.current.clear()
|
||||
return originalXml
|
||||
}
|
||||
|
||||
// originalXml: the diagram before the streamed previews, if any were drawn
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
originalXml: string | undefined,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
@@ -174,14 +222,23 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||
// create_new_diagram, then load it
|
||||
// One undo step (and a version) right away, before the screenshot
|
||||
// check: a stop, another chat or a hand edit during the check then
|
||||
// finds the diagram already in place
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true, "commit", {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
})
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Undo the streamed preview, as a failed edit does: the canvas
|
||||
// keeps the diagram from before this failed call
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
@@ -213,9 +270,12 @@ ${finalXml}
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
validateDiagram &&
|
||||
// At most this many checks per user turn, passed or not
|
||||
validationRetryCountRef.current < MAX_VALIDATION_RETRIES
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
const stopped = watchStop?.()
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
@@ -225,24 +285,28 @@ ${finalXml}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
// Stopped while the screenshot was taken: no check. The
|
||||
// chat waits for this handler, so it must end now.
|
||||
if (stopped?.()) {
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
} else if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
const retryCount =
|
||||
validationRetryCountRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
) || 0
|
||||
// Each retry is a new tool call, so count attempts
|
||||
// per user turn (the chat resets it when the user sends)
|
||||
const attempt = validationRetryCountRef.current + 1
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
// Notify UI that we're validating (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"validating",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
attempt,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
@@ -254,17 +318,12 @@ ${finalXml}
|
||||
)
|
||||
|
||||
if (!result.valid) {
|
||||
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current.set(
|
||||
toolCall.toolCallId,
|
||||
retryCount + 1,
|
||||
)
|
||||
|
||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
@@ -274,7 +333,7 @@ ${finalXml}
|
||||
toolCall.toolCallId,
|
||||
"failed",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
attempt,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
result,
|
||||
imageData: capturedPngData,
|
||||
@@ -285,20 +344,16 @@ ${finalXml}
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Max retries reached - accept the diagram with warning
|
||||
// Last attempt - accept the diagram with warning
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
@@ -314,10 +369,6 @@ ${finalXml}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - clean up retry count
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
@@ -340,6 +391,16 @@ ${finalXml}
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// Cancelled by Stop: the diagram stays, unchecked
|
||||
if ((error as Error)?.name === "AbortError") {
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
return
|
||||
}
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
@@ -373,21 +434,24 @@ ${finalXml}
|
||||
}
|
||||
}
|
||||
|
||||
// originalXml: the diagram before the streamed previews, if any were drawn.
|
||||
// Operations apply to it, the same base XML that streaming used.
|
||||
const handleEditDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
originalXml: string | undefined,
|
||||
) => {
|
||||
const { operations } = toolCall.input as {
|
||||
operations: DiagramOperation[]
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
// On failure, undo the streaming preview so the canvas matches the XML
|
||||
// reported back to the model
|
||||
const restoreOriginal = () => {
|
||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||
}
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
@@ -397,30 +461,23 @@ ${finalXml}
|
||||
currentXml = cachedXML
|
||||
} else {
|
||||
// Last resort: export from iframe
|
||||
currentXml = await onFetchChart(false)
|
||||
currentXml = await onFetchChart()
|
||||
}
|
||||
}
|
||||
|
||||
const { applyDiagramOperations } = await import("@/lib/utils")
|
||||
const { result: editedXml, errors } = applyDiagramOperations(
|
||||
currentXml,
|
||||
operations,
|
||||
)
|
||||
|
||||
// Check for operation errors
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map(
|
||||
(e) =>
|
||||
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
// All or nothing, checked like the MCP server's edit_diagram.
|
||||
// The model sees the first page, so edits target it.
|
||||
const outcome = editDiagram(currentXml, operations, {})
|
||||
if (!outcome.ok) {
|
||||
const reason = outcome.pageError
|
||||
? outcome.errors[0]
|
||||
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Some operations failed:\n${errorMessages}
|
||||
errorText: `${reason}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
@@ -429,49 +486,24 @@ ${currentXml}
|
||||
|
||||
Please check the cell IDs and retry.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(editedXml)
|
||||
if (validationError) {
|
||||
console.warn(
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Edit produced invalid XML: ${validationError}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
${currentXml}
|
||||
\`\`\`
|
||||
|
||||
Please fix the operations to avoid structural issues.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
return
|
||||
}
|
||||
onExport()
|
||||
onDisplayChart(outcome.xml, true, "commit", {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
})
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
|
||||
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
} catch (error) {
|
||||
console.error("[edit_diagram] Failed:", error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -485,8 +517,6 @@ ${currentXml || "No XML available"}
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +526,19 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Nothing to continue: loading the fragment alone would replace the whole diagram
|
||||
if (!partialXmlRef.current) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
|
||||
|
||||
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
@@ -534,10 +577,20 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
// It draws now: it takes the stored originals, as display_diagram
|
||||
const originalXml = prepared.ok ? takeOriginals() : undefined
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true, "commit", {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
})
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
// Loading failed: back to the diagram before the previews
|
||||
if (prepared.ok && originalXml) {
|
||||
onDisplayChart(originalXml, true, "revert")
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
|
||||
+122
-34
@@ -13,6 +13,7 @@ import {
|
||||
flattenModels,
|
||||
type ModelConfig,
|
||||
type MultiModelConfig,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
@@ -63,6 +64,28 @@ function migrateOldConfig(): MultiModelConfig | null {
|
||||
return config
|
||||
}
|
||||
|
||||
const isKnownProvider = (p: { provider: string }) =>
|
||||
Object.hasOwn(PROVIDER_INFO, p.provider)
|
||||
|
||||
/**
|
||||
* The stored config without providers this version does not know (saved
|
||||
* by another version, or edited by hand): they would break every list of
|
||||
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
||||
*/
|
||||
function parseStoredConfig(stored: string): MultiModelConfig {
|
||||
const config = JSON.parse(stored) as MultiModelConfig
|
||||
const known = config.providers.filter(isKnownProvider)
|
||||
if (known.length < config.providers.length) {
|
||||
console.warn(
|
||||
"Skipped saved providers this version does not know:",
|
||||
config.providers
|
||||
.filter((p) => !isKnownProvider(p))
|
||||
.map((p) => p.provider),
|
||||
)
|
||||
}
|
||||
return { ...config, providers: known }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from localStorage
|
||||
*/
|
||||
@@ -73,7 +96,7 @@ function loadConfig(): MultiModelConfig {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored) as MultiModelConfig
|
||||
return parseStoredConfig(stored)
|
||||
} catch {
|
||||
console.error("Failed to parse model config")
|
||||
}
|
||||
@@ -98,7 +121,35 @@ function loadConfig(): MultiModelConfig {
|
||||
*/
|
||||
function saveConfig(config: MultiModelConfig): void {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
// Providers this version does not know are not in config: keep them,
|
||||
// with their keys, for the version that saved them
|
||||
let unknown: MultiModelConfig["providers"] = []
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
||||
if (stored) {
|
||||
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
||||
(p) => !isKnownProvider(p),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Unreadable: nothing to keep
|
||||
}
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.modelConfigs,
|
||||
JSON.stringify({
|
||||
...config,
|
||||
providers: [...config.providers, ...unknown],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server model to fall back to: the one marked default, else the first one
|
||||
*/
|
||||
function defaultServerModelId(
|
||||
serverModels: FlattenedServerModel[],
|
||||
): string | undefined {
|
||||
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
||||
}
|
||||
|
||||
export interface UseModelConfigReturn {
|
||||
@@ -144,10 +195,25 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Load server models on mount (if any)
|
||||
// Pick up config changes saved by other tabs, so this tab neither shows a
|
||||
// stale model nor overwrites their changes on its next save
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
||||
}
|
||||
window.addEventListener("storage", handleStorage)
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [])
|
||||
|
||||
// Load server models on mount (if any), and again when the desktop app
|
||||
// restarted its server for another preset
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
loadServerModels()
|
||||
return window.electronAPI?.onServerRestarted?.(loadServerModels)
|
||||
}, [])
|
||||
|
||||
function loadServerModels() {
|
||||
fetch(getApiEndpoint("/api/server-models"))
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
@@ -165,24 +231,37 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
setServerModels(raw)
|
||||
setServerLoaded(true)
|
||||
|
||||
// Auto-select default server model if no model is currently selected
|
||||
// Auto-select the default server model if no model is selected,
|
||||
// or if the saved server model is gone (renamed or removed)
|
||||
setConfig((prev) => {
|
||||
if (!prev.selectedModelId && raw.length > 0) {
|
||||
const defaultModel = raw.find((m) => m.isDefault)
|
||||
if (defaultModel) {
|
||||
return { ...prev, selectedModelId: defaultModel.id }
|
||||
}
|
||||
// If no default marked, use first server model
|
||||
return { ...prev, selectedModelId: raw[0].id }
|
||||
}
|
||||
return prev
|
||||
const id = prev.selectedModelId
|
||||
const isStale =
|
||||
id?.startsWith("server:") &&
|
||||
!raw.some((m) => m.id === id)
|
||||
if (id && !isStale) return prev
|
||||
// Saved before non-ASCII characters in provider names
|
||||
// got into the id: they were dropped from it
|
||||
const renamed = raw.filter(
|
||||
(m) =>
|
||||
`server:${m.providerLabel
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
|
||||
)
|
||||
const fallback =
|
||||
renamed.length === 1
|
||||
? renamed[0].id
|
||||
: defaultServerModelId(raw)
|
||||
return fallback === id
|
||||
? prev
|
||||
: { ...prev, selectedModelId: fallback }
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error while loading server models:", error)
|
||||
setServerLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Save config whenever it changes (after initial load)
|
||||
useEffect(() => {
|
||||
@@ -260,24 +339,31 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteProvider = useCallback((providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
const deleteProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
|
||||
// Clear selected model if it belongs to deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||
? undefined
|
||||
: prev.selectedModelId
|
||||
// Fall back to the default server model if the selected model
|
||||
// belongs to the deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId &&
|
||||
modelIds.includes(prev.selectedModelId)
|
||||
? defaultServerModelId(serverModels)
|
||||
: prev.selectedModelId
|
||||
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter(
|
||||
(p) => p.id !== providerId,
|
||||
),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
},
|
||||
[serverModels],
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(providerId: string, modelId: string): ModelConfig => {
|
||||
@@ -334,14 +420,15 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
}
|
||||
: p,
|
||||
),
|
||||
// Clear selected model if it was deleted
|
||||
// Fall back to the default server model if the selected model
|
||||
// was deleted
|
||||
selectedModelId:
|
||||
prev.selectedModelId === modelConfigId
|
||||
? undefined
|
||||
? defaultServerModelId(serverModels)
|
||||
: prev.selectedModelId,
|
||||
}))
|
||||
},
|
||||
[],
|
||||
[serverModels],
|
||||
)
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
@@ -428,7 +515,8 @@ export function getSelectedAIConfig(): {
|
||||
|
||||
let config: MultiModelConfig
|
||||
try {
|
||||
config = JSON.parse(stored)
|
||||
// Unknown providers would break the model lookup below
|
||||
config = parseStoredConfig(stored)
|
||||
} catch {
|
||||
return { ...empty, accessCode }
|
||||
}
|
||||
|
||||
+254
-102
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type ChatSession,
|
||||
createEmptySession,
|
||||
@@ -11,17 +13,38 @@ import {
|
||||
getSession,
|
||||
isIndexedDBAvailable,
|
||||
migrateFromLocalStorage,
|
||||
readSessionCount,
|
||||
renameSession as renameSessionInDB,
|
||||
type SessionMetadata,
|
||||
type StoredMessage,
|
||||
saveSession,
|
||||
} from "@/lib/session-storage"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import {
|
||||
type DiagramVersion,
|
||||
versionsFromLegacyHistory,
|
||||
} from "@/stores/versions-store"
|
||||
|
||||
export interface SessionData {
|
||||
messages: StoredMessage[]
|
||||
xmlSnapshots: [number, string][]
|
||||
diagramXml: string
|
||||
thumbnailDataUrl?: string
|
||||
diagramHistory?: { svg: string; xml: string }[]
|
||||
versions?: DiagramVersion[]
|
||||
/** Title for a session created by this save (default: first message) */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** Versions of a stored session, converting the old history format */
|
||||
export function getSessionVersions(session: ChatSession): DiagramVersion[] {
|
||||
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
|
||||
}
|
||||
|
||||
// Taken right before a save's data is read: the chat on screen then, and
|
||||
// the order of the reads
|
||||
export interface SaveTicket {
|
||||
generation: number
|
||||
seq: number
|
||||
}
|
||||
|
||||
export interface UseSessionManagerReturn {
|
||||
@@ -35,13 +58,28 @@ export interface UseSessionManagerReturn {
|
||||
// Actions
|
||||
switchSession: (id: string) => Promise<SessionData | null>
|
||||
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||
// ticket: getSaveTicket() before the data was read (by default, now).
|
||||
// The save is dropped if another chat is on screen when its turn comes,
|
||||
// or if a copy of this chat read later was saved already.
|
||||
// Resolves to false when the save failed (the user was told)
|
||||
saveCurrentSession: (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
) => Promise<void>
|
||||
ticket?: SaveTicket,
|
||||
) => Promise<boolean>
|
||||
refreshSessions: () => Promise<void>
|
||||
clearCurrentSession: () => void
|
||||
getChatGeneration: () => number
|
||||
getSaveTicket: () => SaveTicket
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
// focus also fires each time the user clicks back from the draw.io iframe
|
||||
const FOCUS_REFRESH_INTERVAL_MS = 30_000
|
||||
|
||||
function notifySaveFailed(message: string) {
|
||||
// Same id, so repeated failures update one toast instead of stacking
|
||||
toast.error(message, { id: "session-save-failed", duration: 8000 })
|
||||
}
|
||||
|
||||
interface UseSessionManagerOptions {
|
||||
@@ -53,6 +91,7 @@ export function useSessionManager(
|
||||
options: UseSessionManagerOptions = {},
|
||||
): UseSessionManagerReturn {
|
||||
const { initialSessionId } = options
|
||||
const dict = useDictionary()
|
||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||
null,
|
||||
@@ -66,6 +105,23 @@ export function useSessionManager(
|
||||
const isInitializedRef = useRef(false)
|
||||
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||
const urlChangeSequenceRef = useRef(0)
|
||||
// The chat on screen, read by saves that run after a render or a wait
|
||||
const currentSessionRef = useRef<ChatSession | null>(null)
|
||||
// Goes up each time another chat is put on screen (creating the
|
||||
// session of the chat on screen does not count)
|
||||
const chatGenerationRef = useRef(0)
|
||||
// Saves run one at a time, so two saves of a new chat create it once
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
// The last ticket number, and that of the newest data saved
|
||||
const saveSeqRef = useRef(0)
|
||||
const savedSeqRef = useRef(0)
|
||||
|
||||
const changeChat = useCallback((session: ChatSession | null) => {
|
||||
chatGenerationRef.current++
|
||||
currentSessionRef.current = session
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session?.id ?? null)
|
||||
}, [])
|
||||
|
||||
// Load sessions list
|
||||
const refreshSessions = useCallback(async () => {
|
||||
@@ -101,14 +157,36 @@ export function useSessionManager(
|
||||
// Load sessions list
|
||||
const metadata = await getAllSessionMetadata()
|
||||
setSessions(metadata)
|
||||
// The desktop app may try its other port next launch, where
|
||||
// an older version may have saved the chats: only when this
|
||||
// origin surely has none (a failed read is not "none") and
|
||||
// keeps no model settings or keys either
|
||||
if (window.electronAPI?.chatsLoaded) {
|
||||
const count = await readSessionCount()
|
||||
// The app saves an empty config on its first load; the
|
||||
// providers are what holds the keys, besides an access
|
||||
// code
|
||||
let hasSettings = true
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
|
||||
"{}",
|
||||
)
|
||||
hasSettings =
|
||||
(config.providers?.length ?? 0) > 0 ||
|
||||
!!localStorage.getItem(STORAGE_KEYS.accessCode)
|
||||
} catch {
|
||||
// Unreadable: treat as settings, and stay
|
||||
}
|
||||
if (count !== null && !hasSettings) {
|
||||
window.electronAPI.chatsLoaded(count).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Only load a session if initialSessionId is provided (from URL param)
|
||||
if (initialSessionId) {
|
||||
const session = await getSession(initialSessionId)
|
||||
if (session) {
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
}
|
||||
if (session) changeChat(session)
|
||||
// If session not found, stay in blank state (URL has invalid session ID)
|
||||
}
|
||||
// If no initialSessionId, start with blank state (no auto-restore)
|
||||
@@ -120,7 +198,7 @@ export function useSessionManager(
|
||||
}
|
||||
|
||||
init()
|
||||
}, [initialSessionId])
|
||||
}, [initialSessionId, changeChat])
|
||||
|
||||
// Handle URL session ID changes after initialization
|
||||
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||
@@ -135,6 +213,7 @@ export function useSessionManager(
|
||||
|
||||
async function handleSessionIdChange() {
|
||||
if (initialSessionId) {
|
||||
const generation = chatGenerationRef.current
|
||||
// URL has session ID - load it
|
||||
const session = await getSession(initialSessionId)
|
||||
|
||||
@@ -143,16 +222,13 @@ export function useSessionManager(
|
||||
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||
return
|
||||
}
|
||||
// Another chat was put on screen meanwhile (New Chat right
|
||||
// after this one got its session id in the URL): keep it
|
||||
if (generation !== chatGenerationRef.current) return
|
||||
|
||||
if (session) {
|
||||
// Only update if the session is different from current
|
||||
setCurrentSessionId((current) => {
|
||||
if (current !== session.id) {
|
||||
setCurrentSession(session)
|
||||
return session.id
|
||||
}
|
||||
return current
|
||||
})
|
||||
// Only update if the session is different from current
|
||||
if (session && currentSessionRef.current?.id !== session.id) {
|
||||
changeChat(session)
|
||||
}
|
||||
}
|
||||
// Removed: else clause that clears session
|
||||
@@ -161,11 +237,17 @@ export function useSessionManager(
|
||||
}
|
||||
|
||||
handleSessionIdChange()
|
||||
}, [initialSessionId, isAvailable])
|
||||
}, [initialSessionId, isAvailable, changeChat])
|
||||
|
||||
// Refresh sessions on window focus (multi-tab sync)
|
||||
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
||||
const lastFocusRefreshRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastFocusRefreshRef.current = now
|
||||
refreshSessions()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
@@ -177,9 +259,11 @@ export function useSessionManager(
|
||||
async (id: string): Promise<SessionData | null> => {
|
||||
if (id === currentSessionId) return null
|
||||
|
||||
// Save current session first if it has messages
|
||||
if (currentSession && currentSession.messages.length > 0) {
|
||||
await saveSession(currentSession)
|
||||
// Save current session first if it has messages (as saved
|
||||
// last: the caller may have just saved it)
|
||||
const current = currentSessionRef.current
|
||||
if (current && current.messages.length > 0) {
|
||||
await saveSession(current)
|
||||
}
|
||||
|
||||
// Load the target session
|
||||
@@ -189,19 +273,17 @@ export function useSessionManager(
|
||||
return null
|
||||
}
|
||||
|
||||
// Update state
|
||||
setCurrentSession(session)
|
||||
setCurrentSessionId(session.id)
|
||||
changeChat(session)
|
||||
|
||||
return {
|
||||
messages: session.messages,
|
||||
xmlSnapshots: session.xmlSnapshots,
|
||||
diagramXml: session.diagramXml,
|
||||
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||
diagramHistory: session.diagramHistory,
|
||||
versions: getSessionVersions(session),
|
||||
}
|
||||
},
|
||||
[currentSessionId, currentSession],
|
||||
[currentSessionId, changeChat],
|
||||
)
|
||||
|
||||
// Delete a session
|
||||
@@ -211,101 +293,168 @@ export function useSessionManager(
|
||||
await deleteSessionFromDB(id)
|
||||
|
||||
// If deleting current session, clear state (caller will show new empty session)
|
||||
if (wasCurrentSession) {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}
|
||||
if (wasCurrentSession) changeChat(null)
|
||||
|
||||
await refreshSessions()
|
||||
|
||||
return { wasCurrentSession }
|
||||
},
|
||||
[currentSessionId, refreshSessions],
|
||||
[currentSessionId, refreshSessions, changeChat],
|
||||
)
|
||||
|
||||
// Save current session data (debounced externally by caller)
|
||||
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||
const saveCurrentSession = useCallback(
|
||||
async (
|
||||
data: SessionData,
|
||||
forSessionId?: string | null,
|
||||
): Promise<void> => {
|
||||
// If forSessionId is provided, verify it matches current session
|
||||
// This prevents stale debounced saves from overwriting a newly switched session
|
||||
if (
|
||||
forSessionId !== undefined &&
|
||||
forSessionId !== currentSessionId
|
||||
) {
|
||||
return
|
||||
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => {
|
||||
// The data is of the chat on screen when it was read
|
||||
const { generation, seq } = ticket ?? {
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}
|
||||
const run = async (): Promise<boolean> => {
|
||||
// That chat is no longer on screen (leaving it saved it)
|
||||
if (generation !== chatGenerationRef.current) return true
|
||||
// A copy read later was saved already (one that waited for
|
||||
// its thumbnail must not undo it)
|
||||
if (seq < savedSeqRef.current) return true
|
||||
// Nothing can be stored without IndexedDB
|
||||
if (!isIndexedDBAvailable()) return true
|
||||
// The user may put another chat on screen while this one is
|
||||
// written; the stored copy is still right, the state is not
|
||||
const stillOnScreen = () =>
|
||||
chatGenerationRef.current === generation
|
||||
const currentSession = currentSessionRef.current
|
||||
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
const newSession: ChatSession = {
|
||||
...createEmptySession(),
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
const newSession: ChatSession = {
|
||||
...createEmptySession(),
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
versions: data.versions,
|
||||
title: data.title || extractTitle(data.messages),
|
||||
}
|
||||
// Without a stored session, keep no session id (it would end
|
||||
// up in the URL and point to nothing after a reload)
|
||||
if (!(await saveSession(newSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
await enforceSessionLimit()
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = newSession
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
}
|
||||
await refreshSessions()
|
||||
return true
|
||||
}
|
||||
|
||||
// Update existing session
|
||||
const updatedSession: ChatSession = {
|
||||
...currentSession,
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ??
|
||||
currentSession.thumbnailDataUrl,
|
||||
versions:
|
||||
data.versions ?? getSessionVersions(currentSession),
|
||||
diagramHistory: undefined,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
currentSession.title === "New Chat" &&
|
||||
data.messages.length > 0
|
||||
? extractTitle(data.messages)
|
||||
: currentSession.title,
|
||||
}
|
||||
await saveSession(newSession)
|
||||
await enforceSessionLimit()
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
await refreshSessions()
|
||||
return
|
||||
|
||||
if (!(await saveSession(updatedSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return false
|
||||
}
|
||||
savedSeqRef.current = seq
|
||||
if (stillOnScreen()) {
|
||||
currentSessionRef.current = updatedSession
|
||||
setCurrentSession(updatedSession)
|
||||
}
|
||||
|
||||
// Update sessions list metadata
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === updatedSession.id
|
||||
? {
|
||||
...s,
|
||||
title: updatedSession.title,
|
||||
updatedAt: updatedSession.updatedAt,
|
||||
messageCount: updatedSession.messages.length,
|
||||
hasDiagram:
|
||||
!!updatedSession.diagramXml &&
|
||||
updatedSession.diagramXml.trim().length >
|
||||
0,
|
||||
thumbnailDataUrl:
|
||||
updatedSession.thumbnailDataUrl,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// Update existing session
|
||||
const updatedSession: ChatSession = {
|
||||
...currentSession,
|
||||
messages: data.messages,
|
||||
xmlSnapshots: data.xmlSnapshots,
|
||||
diagramXml: data.diagramXml,
|
||||
thumbnailDataUrl:
|
||||
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||
diagramHistory:
|
||||
data.diagramHistory ?? currentSession.diagramHistory,
|
||||
updatedAt: Date.now(),
|
||||
// Update title if it's still default and we have messages
|
||||
title:
|
||||
currentSession.title === "New Chat" &&
|
||||
data.messages.length > 0
|
||||
? extractTitle(data.messages)
|
||||
: currentSession.title,
|
||||
}
|
||||
|
||||
await saveSession(updatedSession)
|
||||
setCurrentSession(updatedSession)
|
||||
|
||||
// Update sessions list metadata
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === updatedSession.id
|
||||
? {
|
||||
...s,
|
||||
title: updatedSession.title,
|
||||
updatedAt: updatedSession.updatedAt,
|
||||
messageCount: updatedSession.messages.length,
|
||||
hasDiagram:
|
||||
!!updatedSession.diagramXml &&
|
||||
updatedSession.diagramXml.trim().length > 0,
|
||||
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
)
|
||||
const result = saveQueueRef.current.then(run)
|
||||
saveQueueRef.current = result.catch(() => {})
|
||||
return result
|
||||
},
|
||||
[currentSession, currentSessionId, refreshSessions],
|
||||
[refreshSessions, dict],
|
||||
)
|
||||
|
||||
// Clear current session state (for starting fresh without loading another session)
|
||||
const clearCurrentSession = useCallback(() => {
|
||||
setCurrentSession(null)
|
||||
setCurrentSessionId(null)
|
||||
}, [])
|
||||
changeChat(null)
|
||||
}, [changeChat])
|
||||
|
||||
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
|
||||
|
||||
const getSaveTicket = useCallback(
|
||||
(): SaveTicket => ({
|
||||
generation: chatGenerationRef.current,
|
||||
seq: ++saveSeqRef.current,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const renameSession = useCallback(
|
||||
(id: string, title: string): Promise<void> => {
|
||||
const trimmed = title.trim().slice(0, 100)
|
||||
if (!trimmed) return Promise.resolve()
|
||||
// In the save queue, so a save running now cannot put the old
|
||||
// title back
|
||||
const run = async () => {
|
||||
const updated = await renameSessionInDB(id, trimmed)
|
||||
if (!updated) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
const current = currentSessionRef.current
|
||||
if (current?.id === id) {
|
||||
currentSessionRef.current = { ...current, title: trimmed }
|
||||
setCurrentSession(currentSessionRef.current)
|
||||
}
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === id ? { ...s, title: trimmed } : s,
|
||||
),
|
||||
)
|
||||
}
|
||||
const result = saveQueueRef.current.then(run)
|
||||
saveQueueRef.current = result.catch(() => {})
|
||||
return result
|
||||
},
|
||||
[dict],
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -318,5 +467,8 @@ export function useSessionManager(
|
||||
saveCurrentSession,
|
||||
refreshSessions,
|
||||
clearCurrentSession,
|
||||
getChatGeneration,
|
||||
getSaveTicket,
|
||||
renameSession,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user