mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e785bcb5 | ||
|
|
60105ddb51 | ||
|
|
4c7b2cc0c5 | ||
|
|
07f3e3c2a3 | ||
|
|
8ab837679e | ||
|
|
cb066a4825 | ||
|
|
77be43d996 | ||
|
|
c774a39c33 | ||
|
|
3d1a020b35 | ||
|
|
8d690503d4 | ||
|
|
4b4f36e374 | ||
|
|
a9091e96f1 | ||
|
|
213b672e92 | ||
|
|
d1ee1594f1 |
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Lint check
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
@@ -4,14 +4,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- master
|
|
||||||
- dev
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- master
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
name: Publish MCP Server
|
name: Publish MCP Server
|
||||||
|
|
||||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
# (no token, no OTP). Triggers when packages/mcp-server or the web app code
|
||||||
|
# bundled into the canvas shell (scripts/build-shell.mjs) changes on main;
|
||||||
# skips silently if the package.json version is already on npm — so a
|
# skips silently if the package.json version is already on npm — so a
|
||||||
# release is just "bump the version in a PR and merge".
|
# release is just "bump the version in a PR and merge".
|
||||||
on:
|
on:
|
||||||
@@ -10,6 +11,16 @@ on:
|
|||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "packages/mcp-server/**"
|
- "packages/mcp-server/**"
|
||||||
|
# The canvas shell is bundled from these with the root's lockfile
|
||||||
|
- "components/**"
|
||||||
|
- "contexts/**"
|
||||||
|
- "hooks/**"
|
||||||
|
- "lib/**"
|
||||||
|
- "stores/**"
|
||||||
|
- "app/globals.css"
|
||||||
|
- "package-lock.json"
|
||||||
|
# The package's build scripts import the root's scripts/drawio-zip.mjs
|
||||||
|
- "scripts/**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -35,7 +46,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
packages/mcp-server/package-lock.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
# Trusted publishing requires npm >= 11.5.1
|
# Trusted publishing requires npm >= 11.5.1
|
||||||
@@ -54,6 +67,13 @@ jobs:
|
|||||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The canvas shell (scripts/build-shell.mjs) is bundled from the web
|
||||||
|
# app's components with the root's esbuild, Tailwind and React
|
||||||
|
- name: Install repository dependencies
|
||||||
|
if: steps.version.outputs.publish == 'true'
|
||||||
|
working-directory: .
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -62,6 +82,9 @@ jobs:
|
|||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
# The build downloads the pinned draw.war from GitHub releases
|
||||||
|
# (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into
|
||||||
|
# dist/drawio; check-package confirms it is in the tarball
|
||||||
- name: Build and check package contents
|
- name: Build and check package contents
|
||||||
if: steps.version.outputs.publish == 'true'
|
if: steps.version.outputs.publish == 'true'
|
||||||
run: npm run build && npm run check-package
|
run: npm run build && npm run check-package
|
||||||
|
|||||||
+101
-7
@@ -6,6 +6,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-unit:
|
lint-and-unit:
|
||||||
name: Lint & Unit Tests
|
name: Lint & Unit Tests
|
||||||
@@ -16,32 +23,119 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test -- --run
|
run: npm run test -- --run
|
||||||
|
|
||||||
# The MCP server package ships its own vitest because its DOM polyfill
|
# The MCP server package: its own vitest (its DOM polyfill, linkedom, needs
|
||||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
# `environment: node`, while the root vitest uses jsdom for the Next.js
|
||||||
# for the Next.js app. Install + run its tests separately so CI catches
|
# app), the npm package as it is built, the draw.io file list, and the
|
||||||
# multi-page mxfile regressions.
|
# canvas shell in a browser.
|
||||||
|
mcp-shell:
|
||||||
|
name: MCP Server & Shell
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# HEAD^1 is the base branch for the version bump reminder below
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: |
|
||||||
|
package-lock.json
|
||||||
|
packages/mcp-server/package-lock.json
|
||||||
|
|
||||||
|
# The shell is bundled from the web app's components with the root's
|
||||||
|
# esbuild, Tailwind and React; Playwright comes from the root too
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Install MCP server dependencies
|
- name: Install MCP server dependencies
|
||||||
run: npm --prefix packages/mcp-server ci
|
run: npm --prefix packages/mcp-server ci
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v5
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Install Playwright deps (cached)
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
|
||||||
- name: Run MCP server unit tests
|
- name: Run MCP server unit tests
|
||||||
run: npm --prefix packages/mcp-server test
|
run: npm --prefix packages/mcp-server test
|
||||||
|
|
||||||
# Tests run from src/, so check the built npm package separately
|
# Tests run from src/, so check the built npm package separately. The
|
||||||
|
# build downloads the pinned draw.war and bundles a trimmed draw.io.
|
||||||
- name: Build MCP server and check package contents
|
- name: Build MCP server and check package contents
|
||||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||||
|
|
||||||
|
# The trimmed draw.io is cut from this list; the guard drives the full
|
||||||
|
# copy (public/drawio) in Chromium and fails when the editor requests a
|
||||||
|
# file the list does not have
|
||||||
|
- name: Download the full draw.io
|
||||||
|
run: node scripts/fetch-drawio.mjs
|
||||||
|
|
||||||
|
- name: Check the MCP draw.io file list
|
||||||
|
run: npm --prefix packages/mcp-server run check-drawio
|
||||||
|
|
||||||
|
# The canvas shell in a browser, from the package as npm installs it:
|
||||||
|
# the packed tarball goes into an empty directory, its dist/index.js is
|
||||||
|
# started over stdio and headless Chromium opens the URL start_session
|
||||||
|
# returns (packages/mcp-server/tests/e2e)
|
||||||
|
- name: Run the MCP shell E2E tests on the packed tarball
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install"
|
||||||
|
npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack"
|
||||||
|
npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz
|
||||||
|
MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \
|
||||||
|
npm --prefix packages/mcp-server run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
# The shell bundles the web app's canvas and the root's dependencies,
|
||||||
|
# so a change under these paths (the ones publish-mcp.yml triggers on)
|
||||||
|
# reaches users with the next MCP release, and publish-mcp.yml only
|
||||||
|
# publishes a new version number
|
||||||
|
- name: Remind to bump the MCP version when bundled code changed
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server)
|
||||||
|
base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
|
||||||
|
head_version=$(node -p "require('./packages/mcp-server/package.json').version")
|
||||||
|
if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then
|
||||||
|
echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report-mcp-shell
|
||||||
|
path: packages/mcp-server/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
# Six jobs at once, each with a sixth of the tests and one worker: the
|
# Six jobs at once, each with a sixth of the tests and one worker: the
|
||||||
# same conditions per test, a fraction of the time
|
# same conditions per test, a fraction of the time
|
||||||
@@ -57,7 +151,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "24"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
|
||||||
|
# days without activity the issue gets a reminder and the "no response"
|
||||||
|
# label; after 15 more days it is closed. A reply from the reporter removes
|
||||||
|
# both labels, so the issue shows up as needing the maintainer again.
|
||||||
|
name: Wait for confirmation
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remind-and-close:
|
||||||
|
if: github.event_name != 'issue_comment'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
only-issue-labels: "wait for confrim"
|
||||||
|
days-before-issue-stale: 15
|
||||||
|
days-before-issue-close: 15
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
stale-issue-label: "no response"
|
||||||
|
stale-issue-message: >-
|
||||||
|
Still there? We asked for more information 15 days ago. A reply
|
||||||
|
would help; if we hear nothing in another 15 days this issue will
|
||||||
|
be closed. It can be reopened any time.
|
||||||
|
close-issue-message: >-
|
||||||
|
No reply in 30 days, so this issue is closed for now. If the
|
||||||
|
problem is still there, reply or reopen it.
|
||||||
|
close-issue-reason: not_planned
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
|
||||||
|
reporter-replied:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'issue_comment' &&
|
||||||
|
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
|
||||||
|
github.event.comment.user.type != 'Bot' &&
|
||||||
|
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: The reporter answered, so the issue is the maintainer's again
|
||||||
|
run: >-
|
||||||
|
gh issue edit "$NUMBER" --repo "$REPO"
|
||||||
|
--remove-label "wait for confrim" --remove-label "no response"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
@@ -16,6 +16,8 @@ packages/*/dist
|
|||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
packages/*/playwright-report/
|
||||||
|
packages/*/test-results/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
|
Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
|
||||||
|
|
||||||
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||||
|
- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select
|
||||||
|
- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead)
|
||||||
- A screenshot tool, so the AI can look at the rendered diagram and fix it
|
- A screenshot tool, so the AI can look at the rendered diagram and fix it
|
||||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
|
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
|
||||||
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
|
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
|
||||||
|
- Ask the AI to undo, and keep your own drawing rules in `~/.next-ai-drawio/instructions.md`
|
||||||
|
|
||||||
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
|
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
|
||||||
|
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { FaGithub } from "react-icons/fa"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "关于 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
|
||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutCN() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
编辑器
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about/cn"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
关于
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
aria-label="在GitHub上查看"
|
|
||||||
>
|
|
||||||
<FaGithub className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<article className="prose prose-lg max-w-none">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
|
||||||
AI驱动的图表创建工具 - 对话、绘制、可视化
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
|
||||||
{/* Bring Your Own Key */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
|
||||||
使用自己的 API Key
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
|
||||||
您也可以使用自己的 API
|
|
||||||
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
|
||||||
您的 Key
|
|
||||||
仅保存在浏览器本地,不会被存储在服务器上。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-700">
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
功能特性
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>LLM驱动的图表创建</strong>
|
|
||||||
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>基于图像的图表复制</strong>
|
|
||||||
:上传现有图表或图像,让AI自动复制和增强
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>版本与撤销</strong>
|
|
||||||
:AI
|
|
||||||
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
|
|
||||||
Ctrl+Z 也能撤回
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>交互式聊天界面</strong>
|
|
||||||
:与AI实时对话来完善您的图表
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>AWS架构图支持</strong>
|
|
||||||
:专门支持生成AWS架构图
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>动画连接器</strong>
|
|
||||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
示例
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下是一些示例提示词及其生成的图表:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* ResNet50 Architecture */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ResNet50模型架构动画
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
|
||||||
<Image
|
|
||||||
src="/resnet50.svg"
|
|
||||||
alt="ResNet50模型架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
RAG技术图
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
|
||||||
architecture diagram for{" "}
|
|
||||||
<strong>chat application</strong>. Use
|
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/rag_prod.svg"
|
|
||||||
alt="RAG架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
React和AWS认证流程
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="认证架构图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
敏捷Scrum流程
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="敏捷Scrum流程图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
开放式创新
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="开放式创新图"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How It Works */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
工作原理
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>Next.js</strong>:用于前端框架和路由
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):用于流式AI响应和多提供商支持
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>draw.io</strong>
|
|
||||||
:内置在应用里的编辑器,应用可以直接控制它
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
多提供商支持
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
|
||||||
<li>字节跳动豆包</li>
|
|
||||||
<li>AWS Bedrock(默认)</li>
|
|
||||||
<li>
|
|
||||||
OpenAI / OpenAI兼容API(通过{" "}
|
|
||||||
<code>OPENAI_BASE_URL</code>)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
|
||||||
<li>Google AI</li>
|
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
|
||||||
<li>Ollama</li>
|
|
||||||
<li>OpenRouter</li>
|
|
||||||
<li>DeepSeek</li>
|
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
|
||||||
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
支持与联系
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
如果您觉得这个项目有用,请考虑{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
赞助
|
|
||||||
</a>{" "}
|
|
||||||
来帮助托管在线演示站点!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 mt-2">
|
|
||||||
如需支持或咨询,请在{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHub仓库
|
|
||||||
</a>{" "}
|
|
||||||
上提交issue或联系:me[at]jiang.jp
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
打开编辑器
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<p className="text-center text-gray-600 text-sm">
|
|
||||||
Next AI Draw.io - 开源AI驱动的图表生成器
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
import type { Metadata } from "next"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { FaGithub } from "react-icons/fa"
|
|
||||||
import Image from "@/components/image-with-basepath"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "概要 - Next AI Draw.io",
|
|
||||||
description:
|
|
||||||
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
|
||||||
keywords: [
|
|
||||||
"AIダイアグラム",
|
|
||||||
"draw.io",
|
|
||||||
"AWSアーキテクチャ",
|
|
||||||
"GCPダイアグラム",
|
|
||||||
"Azureダイアグラム",
|
|
||||||
"LLM",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutJA() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Next AI Draw.io
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
エディタ
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/about/ja"
|
|
||||||
className="text-blue-600 font-semibold"
|
|
||||||
>
|
|
||||||
概要
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
aria-label="GitHubで見る"
|
|
||||||
>
|
|
||||||
<FaGithub className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<article className="prose prose-lg max-w-none">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
|
||||||
Next AI Draw.io
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
|
||||||
AI搭載のダイアグラム作成ツール -
|
|
||||||
チャット、描画、可視化
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
|
|
||||||
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
|
|
||||||
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
|
|
||||||
{/* Bring Your Own Key */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
|
||||||
自分のAPIキーを使用
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
|
||||||
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
|
||||||
キーはブラウザのローカルに保存され、サーバーには保存されません。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-700">
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
機能
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>LLM搭載のダイアグラム作成</strong>
|
|
||||||
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>画像ベースのダイアグラム複製</strong>
|
|
||||||
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>バージョンと取り消し</strong>
|
|
||||||
:AI
|
|
||||||
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
|
|
||||||
Ctrl+Z を押しても元に戻せます
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
インタラクティブなチャットインターフェース
|
|
||||||
</strong>
|
|
||||||
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>
|
|
||||||
AWSアーキテクチャダイアグラムサポート
|
|
||||||
</strong>
|
|
||||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>アニメーションコネクタ</strong>
|
|
||||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Examples */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
例
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
以下はいくつかのプロンプト例と生成されたダイアグラムです:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* ResNet50 Architecture */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ResNet50モデルアーキテクチャアニメーション
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
|
||||||
<Image
|
|
||||||
src="/resnet50.svg"
|
|
||||||
alt="ResNet50モデルアーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
RAG技術ダイアグラム
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
|
||||||
architecture diagram for{" "}
|
|
||||||
<strong>chat application</strong>. Use
|
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/rag_prod.svg"
|
|
||||||
alt="RAGアーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
ReactとAWSによる認証
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="認証アーキテクチャ図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
アジャイルスクラムプロセス
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="アジャイルスクラム図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
オープンイノベーション
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="オープンイノベーション図"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* How It Works */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
仕組み
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-4">
|
|
||||||
本アプリケーションは以下の技術を使用しています:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
|
||||||
<li>
|
|
||||||
<strong>Next.js</strong>
|
|
||||||
:フロントエンドフレームワークとルーティング
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
|
|
||||||
<code>@ai-sdk/*</code>
|
|
||||||
):ストリーミングAIレスポンスとマルチプロバイダーサポート
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>draw.io</strong>
|
|
||||||
:アプリに同梱されたエディター。アプリから直接操作できます
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
マルチプロバイダーサポート
|
|
||||||
</h2>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
|
||||||
<li>ByteDance Doubao</li>
|
|
||||||
<li>AWS Bedrock(デフォルト)</li>
|
|
||||||
<li>
|
|
||||||
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
|
|
||||||
経由)
|
|
||||||
</li>
|
|
||||||
<li>Anthropic</li>
|
|
||||||
<li>Google AI</li>
|
|
||||||
<li>Google Vertex AI</li>
|
|
||||||
<li>Azure OpenAI</li>
|
|
||||||
<li>Ollama</li>
|
|
||||||
<li>OpenRouter</li>
|
|
||||||
<li>DeepSeek</li>
|
|
||||||
<li>SiliconFlow</li>
|
|
||||||
<li>ModelScope</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-700 mt-4">
|
|
||||||
注:<code>claude-sonnet-4-5</code>
|
|
||||||
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Support */}
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
|
||||||
サポート&お問い合わせ
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700">
|
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
スポンサー
|
|
||||||
</a>{" "}
|
|
||||||
をご検討ください!
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 mt-2">
|
|
||||||
サポートやお問い合わせについては、{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
GitHubリポジトリ
|
|
||||||
</a>{" "}
|
|
||||||
でissueを開くか、ご連絡ください:me[at]jiang.jp
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div className="mt-12 text-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
エディタを開く
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<p className="text-center text-gray-600 text-sm">
|
|
||||||
Next AI Draw.io -
|
|
||||||
オープンソースAI搭載ダイアグラムジェネレーター
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+491
-194
@@ -1,23 +1,420 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import type { ReactNode } from "react"
|
||||||
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
|
import type { Locale } from "@/lib/i18n/config"
|
||||||
|
import { hasLocale } from "@/lib/i18n/dictionaries"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
|
||||||
title: "About - Next AI Draw.io",
|
|
||||||
description:
|
type AboutTexts = {
|
||||||
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
title: string
|
||||||
keywords: [
|
description: string
|
||||||
"AI diagram",
|
keywords: string[]
|
||||||
"draw.io",
|
nav: { editor: string; about: string; github: string }
|
||||||
"AWS architecture",
|
tagline: string
|
||||||
"GCP diagram",
|
byok: { title: string; body: string; note: string }
|
||||||
"Azure diagram",
|
intro: string
|
||||||
"LLM",
|
// Punctuation differs between Latin and CJK text
|
||||||
],
|
colon: string
|
||||||
|
parenOpen: string
|
||||||
|
parenClose: string
|
||||||
|
featuresHeading: string
|
||||||
|
features: { title: string; text: string }[]
|
||||||
|
examplesHeading: string
|
||||||
|
examplesIntro: string
|
||||||
|
examples: Record<ExampleId, { title: string; alt: string }>
|
||||||
|
howHeading: string
|
||||||
|
howIntro: string
|
||||||
|
tech: { nextjs: string; aiSdk: string; drawio: string }
|
||||||
|
howOutro: string
|
||||||
|
providersHeading: string
|
||||||
|
doubao: string
|
||||||
|
bedrock: string
|
||||||
|
// Text around the OPENAI_BASE_URL code span
|
||||||
|
openai: { before: string; after: string }
|
||||||
|
// Text around the claude-sonnet-4-5 code span
|
||||||
|
modelNote: { before: string; after: string }
|
||||||
|
supportHeading: string
|
||||||
|
// Text around the sponsor link
|
||||||
|
sponsor: { before: string; link: string; after: string }
|
||||||
|
// Text around the GitHub repository link
|
||||||
|
contact: { before: string; link: string; after: string }
|
||||||
|
openEditor: string
|
||||||
|
footer: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function About() {
|
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
|
||||||
|
en: {
|
||||||
|
title: "About - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
|
||||||
|
keywords: [
|
||||||
|
"AI diagram",
|
||||||
|
"draw.io",
|
||||||
|
"AWS architecture",
|
||||||
|
"GCP diagram",
|
||||||
|
"Azure diagram",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "Editor", about: "About", github: "View on GitHub" },
|
||||||
|
tagline: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize",
|
||||||
|
byok: {
|
||||||
|
title: "Bring Your Own API Key",
|
||||||
|
body: "You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.",
|
||||||
|
note: "Your key is stored locally in your browser and is never stored on the server.",
|
||||||
|
},
|
||||||
|
intro: "A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.",
|
||||||
|
colon: ": ",
|
||||||
|
parenOpen: " (",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "Features",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM-Powered Diagram Creation",
|
||||||
|
text: "Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image-Based Diagram Replication",
|
||||||
|
text: "Upload existing diagrams or images and have the AI replicate and enhance them automatically",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Versions and Undo",
|
||||||
|
text: "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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Interactive Chat Interface",
|
||||||
|
text: "Communicate with AI to refine your diagrams in real-time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AWS Architecture Diagram Support",
|
||||||
|
text: "Specialized support for generating AWS architecture diagrams",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Animated Connectors",
|
||||||
|
text: "Create dynamic and animated connectors between diagram elements for better visualization",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "Examples",
|
||||||
|
examplesIntro:
|
||||||
|
"Here are some example prompts and their generated diagrams:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "Animated ResNet50 Model Architecture",
|
||||||
|
alt: "Architecture diagram for ResNet50 model",
|
||||||
|
},
|
||||||
|
rag: {
|
||||||
|
title: "RAG Technique Diagram",
|
||||||
|
alt: "RAG Architecture Diagram",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Authentication using React and AWS",
|
||||||
|
alt: "Authentication Architecture Diagram",
|
||||||
|
},
|
||||||
|
scrum: { title: "Agile Scrum Process", alt: "Agile Scrum Diagram" },
|
||||||
|
inno: { title: "Open Innovation", alt: "Open Innovation Diagram" },
|
||||||
|
},
|
||||||
|
howHeading: "How It Works",
|
||||||
|
howIntro: "The application uses the following technologies:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "For the frontend framework and routing",
|
||||||
|
aiSdk: "For streaming AI responses and multi-provider support",
|
||||||
|
drawio: "The editor, bundled with the app so it can be driven directly",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.",
|
||||||
|
providersHeading: "Multi-Provider Support",
|
||||||
|
doubao: "ByteDance Doubao",
|
||||||
|
bedrock: "AWS Bedrock (default)",
|
||||||
|
openai: { before: "OpenAI / OpenAI-compatible APIs (via ", after: ")" },
|
||||||
|
modelNote: {
|
||||||
|
before: "Note that ",
|
||||||
|
after: " has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.",
|
||||||
|
},
|
||||||
|
supportHeading: "Support & Contact",
|
||||||
|
sponsor: {
|
||||||
|
before: "If you find this project useful, please consider",
|
||||||
|
link: "sponsoring",
|
||||||
|
after: "to help host the live demo site!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "For support or inquiries, please open an issue on the",
|
||||||
|
link: "GitHub repository",
|
||||||
|
after: "or contact: me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "Open Editor",
|
||||||
|
footer: "Next AI Draw.io - Open Source AI-Powered Diagram Generator",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "关于 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
|
||||||
|
keywords: [
|
||||||
|
"AI图表",
|
||||||
|
"draw.io",
|
||||||
|
"AWS架构",
|
||||||
|
"GCP图表",
|
||||||
|
"Azure图表",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "编辑器", about: "关于", github: "在GitHub上查看" },
|
||||||
|
tagline: "AI驱动的图表创建工具 - 对话、绘制、可视化",
|
||||||
|
byok: {
|
||||||
|
title: "使用自己的 API Key",
|
||||||
|
body: "您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。",
|
||||||
|
note: "您的 Key 仅保存在浏览器本地,不会被存储在服务器上。",
|
||||||
|
},
|
||||||
|
intro: "一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。",
|
||||||
|
colon: ":",
|
||||||
|
parenOpen: "(",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "功能特性",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM驱动的图表创建",
|
||||||
|
text: "利用大语言模型通过自然语言命令直接创建和操作draw.io图表",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "基于图像的图表复制",
|
||||||
|
text: "上传现有图表或图像,让AI自动复制和增强",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "版本与撤销",
|
||||||
|
text: "AI 的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按 Ctrl+Z 也能撤回",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "交互式聊天界面",
|
||||||
|
text: "与AI实时对话来完善您的图表",
|
||||||
|
},
|
||||||
|
{ title: "AWS架构图支持", text: "专门支持生成AWS架构图" },
|
||||||
|
{
|
||||||
|
title: "动画连接器",
|
||||||
|
text: "在图表元素之间创建动态动画连接器,实现更好的可视化效果",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "示例",
|
||||||
|
examplesIntro: "以下是一些示例提示词及其生成的图表:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "ResNet50模型架构动画",
|
||||||
|
alt: "ResNet50模型架构图",
|
||||||
|
},
|
||||||
|
rag: { title: "RAG技术图", alt: "RAG架构图" },
|
||||||
|
auth: { title: "React和AWS认证流程", alt: "认证架构图" },
|
||||||
|
scrum: { title: "敏捷Scrum流程", alt: "敏捷Scrum流程图" },
|
||||||
|
inno: { title: "开放式创新", alt: "开放式创新图" },
|
||||||
|
},
|
||||||
|
howHeading: "工作原理",
|
||||||
|
howIntro: "本应用使用以下技术:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "用于前端框架和路由",
|
||||||
|
aiSdk: "用于流式AI响应和多提供商支持",
|
||||||
|
drawio: "内置在应用里的编辑器,应用可以直接控制它",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。",
|
||||||
|
providersHeading: "多提供商支持",
|
||||||
|
doubao: "字节跳动豆包",
|
||||||
|
bedrock: "AWS Bedrock(默认)",
|
||||||
|
openai: { before: "OpenAI / OpenAI兼容API(通过 ", after: ")" },
|
||||||
|
modelNote: {
|
||||||
|
before: "注意:",
|
||||||
|
after: " 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。",
|
||||||
|
},
|
||||||
|
supportHeading: "支持与联系",
|
||||||
|
sponsor: {
|
||||||
|
before: "如果您觉得这个项目有用,请考虑",
|
||||||
|
link: "赞助",
|
||||||
|
after: "来帮助托管在线演示站点!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "如需支持或咨询,请在",
|
||||||
|
link: "GitHub仓库",
|
||||||
|
after: "上提交issue或联系:me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "打开编辑器",
|
||||||
|
footer: "Next AI Draw.io - 开源AI驱动的图表生成器",
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
title: "概要 - Next AI Draw.io",
|
||||||
|
description:
|
||||||
|
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
|
||||||
|
keywords: [
|
||||||
|
"AIダイアグラム",
|
||||||
|
"draw.io",
|
||||||
|
"AWSアーキテクチャ",
|
||||||
|
"GCPダイアグラム",
|
||||||
|
"Azureダイアグラム",
|
||||||
|
"LLM",
|
||||||
|
],
|
||||||
|
nav: { editor: "エディタ", about: "概要", github: "GitHubで見る" },
|
||||||
|
tagline: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化",
|
||||||
|
byok: {
|
||||||
|
title: "自分のAPIキーを使用",
|
||||||
|
body: "お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。",
|
||||||
|
note: "キーはブラウザのローカルに保存され、サーバーには保存されません。",
|
||||||
|
},
|
||||||
|
intro: "AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。",
|
||||||
|
colon: ":",
|
||||||
|
parenOpen: "(",
|
||||||
|
parenClose: ")",
|
||||||
|
featuresHeading: "機能",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: "LLM搭載のダイアグラム作成",
|
||||||
|
text: "大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "画像ベースのダイアグラム複製",
|
||||||
|
text: "既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "バージョンと取り消し",
|
||||||
|
text: "AI の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで Ctrl+Z を押しても元に戻せます",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "インタラクティブなチャットインターフェース",
|
||||||
|
text: "AIとリアルタイムでコミュニケーションしてダイアグラムを改善",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AWSアーキテクチャダイアグラムサポート",
|
||||||
|
text: "AWSアーキテクチャダイアグラムの生成を専門的にサポート",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "アニメーションコネクタ",
|
||||||
|
text: "より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
examplesHeading: "例",
|
||||||
|
examplesIntro:
|
||||||
|
"以下はいくつかのプロンプト例と生成されたダイアグラムです:",
|
||||||
|
examples: {
|
||||||
|
resnet50: {
|
||||||
|
title: "ResNet50モデルアーキテクチャアニメーション",
|
||||||
|
alt: "ResNet50モデルアーキテクチャ図",
|
||||||
|
},
|
||||||
|
rag: { title: "RAG技術ダイアグラム", alt: "RAGアーキテクチャ図" },
|
||||||
|
auth: {
|
||||||
|
title: "ReactとAWSによる認証",
|
||||||
|
alt: "認証アーキテクチャ図",
|
||||||
|
},
|
||||||
|
scrum: {
|
||||||
|
title: "アジャイルスクラムプロセス",
|
||||||
|
alt: "アジャイルスクラム図",
|
||||||
|
},
|
||||||
|
inno: {
|
||||||
|
title: "オープンイノベーション",
|
||||||
|
alt: "オープンイノベーション図",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
howHeading: "仕組み",
|
||||||
|
howIntro: "本アプリケーションは以下の技術を使用しています:",
|
||||||
|
tech: {
|
||||||
|
nextjs: "フロントエンドフレームワークとルーティング",
|
||||||
|
aiSdk: "ストリーミングAIレスポンスとマルチプロバイダーサポート",
|
||||||
|
drawio: "アプリに同梱されたエディター。アプリから直接操作できます",
|
||||||
|
},
|
||||||
|
howOutro:
|
||||||
|
"ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。",
|
||||||
|
providersHeading: "マルチプロバイダーサポート",
|
||||||
|
doubao: "ByteDance Doubao",
|
||||||
|
bedrock: "AWS Bedrock(デフォルト)",
|
||||||
|
openai: { before: "OpenAI / OpenAI互換API(", after: "経由)" },
|
||||||
|
modelNote: {
|
||||||
|
before: "注:",
|
||||||
|
after: "はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。",
|
||||||
|
},
|
||||||
|
supportHeading: "サポート&お問い合わせ",
|
||||||
|
sponsor: {
|
||||||
|
before: "このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために",
|
||||||
|
link: "スポンサー",
|
||||||
|
after: "をご検討ください!",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
before: "サポートやお問い合わせについては、",
|
||||||
|
link: "GitHubリポジトリ",
|
||||||
|
after: "でissueを開くか、ご連絡ください:me[at]jiang.jp",
|
||||||
|
},
|
||||||
|
openEditor: "エディタを開く",
|
||||||
|
footer: "Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The example prompts stay in English in every language
|
||||||
|
const examples: { id: ExampleId; src: string; prompt: ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: "resnet50",
|
||||||
|
src: "/resnet50.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Give me an <strong>animated</strong> architecture diagram of the
|
||||||
|
ResNet50 model.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rag",
|
||||||
|
src: "/rag_prod.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate a RAG architecture diagram for{" "}
|
||||||
|
<strong>chat application</strong>. Use connected diagram for
|
||||||
|
data ingestion
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auth",
|
||||||
|
src: "/auth.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate authentication process using React with{" "}
|
||||||
|
<strong>AWS</strong>. Use Serverless architecture.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scrum",
|
||||||
|
src: "/agile_scrum.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Generate agile scrum workflow diagram for software development
|
||||||
|
team.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inno",
|
||||||
|
src: "/inno.svg",
|
||||||
|
prompt: (
|
||||||
|
<>
|
||||||
|
Create visualization of Henry Chesbrough's Open Innovation
|
||||||
|
model.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Traditional Chinese has no About text of its own and shows the Simplified one
|
||||||
|
function textsFor(rawLang: string): AboutTexts {
|
||||||
|
const lang: Locale = hasLocale(rawLang) ? rawLang : "en"
|
||||||
|
return texts[lang === "zh-Hant" ? "zh" : lang]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ lang: string }> }
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { lang } = await params
|
||||||
|
const t = textsFor(lang)
|
||||||
|
return { title: t.title, description: t.description, keywords: t.keywords }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function About({ params }: Props) {
|
||||||
|
const { lang } = await params
|
||||||
|
const t = textsFor(lang)
|
||||||
|
const [hero, ...grid] = examples
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -25,32 +422,32 @@ export default function About() {
|
|||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
className="text-xl font-bold text-gray-900 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 text-sm">
|
<nav className="flex items-center gap-6 text-sm">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
Editor
|
{t.nav.editor}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/about"
|
href={`/${lang}/about`}
|
||||||
className="text-blue-600 font-semibold"
|
className="text-blue-600 font-semibold"
|
||||||
>
|
>
|
||||||
About
|
{t.nav.about}
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
aria-label="View on GitHub"
|
aria-label={t.nav.github}
|
||||||
>
|
>
|
||||||
<FaGithub className="w-5 h-5" />
|
<GitHubIcon className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +463,7 @@ export default function About() {
|
|||||||
Next AI Draw.io
|
Next AI Draw.io
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 font-medium">
|
<p className="text-xl text-gray-600 font-medium">
|
||||||
AI-Powered Diagram Creation Tool - Chat, Draw,
|
{t.tagline}
|
||||||
Visualize
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,92 +473,53 @@ export default function About() {
|
|||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
Bring Your Own API Key
|
{t.byok.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
|
||||||
You can also use your own API key with any
|
{t.byok.body}
|
||||||
supported provider. Click the Settings icon
|
|
||||||
in the chat panel to configure your provider
|
|
||||||
and API key.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||||
Your key is stored locally in your browser
|
{t.byok.note}
|
||||||
and is never stored on the server.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">{t.intro}</p>
|
||||||
A Next.js web application that integrates AI
|
|
||||||
capabilities with draw.io diagrams. Create, modify, and
|
|
||||||
enhance diagrams through natural language commands and
|
|
||||||
AI-assisted visualization.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Features
|
{t.featuresHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
{t.features.map((feature) => (
|
||||||
<strong>LLM-Powered Diagram Creation</strong>:
|
<li key={feature.title}>
|
||||||
Leverage Large Language Models to create and
|
<strong>{feature.title}</strong>
|
||||||
manipulate draw.io diagrams directly through natural
|
{t.colon}
|
||||||
language commands
|
{feature.text}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
))}
|
||||||
<strong>Image-Based Diagram Replication</strong>:
|
|
||||||
Upload existing diagrams or images and have the AI
|
|
||||||
replicate and enhance them automatically
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<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>:
|
|
||||||
Communicate with AI to refine your diagrams in
|
|
||||||
real-time
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>AWS Architecture Diagram Support</strong>:
|
|
||||||
Specialized support for generating AWS architecture
|
|
||||||
diagrams
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Animated Connectors</strong>: Create dynamic
|
|
||||||
and animated connectors between diagram elements for
|
|
||||||
better visualization
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Examples */}
|
{/* Examples */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Examples
|
{t.examplesHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-6">
|
<p className="text-gray-700 mb-6">{t.examplesIntro}</p>
|
||||||
Here are some example prompts and their generated
|
|
||||||
diagrams:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* First example on its own */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated ResNet50 Model Architecture
|
{t.examples[hero.id].title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> {hero.prompt}
|
||||||
<strong>animated</strong> architecture diagram
|
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/resnet50.svg"
|
src={hero.src}
|
||||||
alt="Architecture diagram for ResNet50 model"
|
alt={t.examples[hero.id].alt}
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
@@ -172,125 +529,67 @@ export default function About() {
|
|||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
{grid.map((example) => (
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<div key={example.id} className="text-center">
|
||||||
RAG Technique Diagram
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
</h3>
|
{t.examples[example.id].title}
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
</h3>
|
||||||
<strong>Prompt:</strong> Generate a RAG
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
architecture diagram for{" "}
|
<strong>Prompt:</strong>{" "}
|
||||||
<strong>chat application</strong>. Use
|
{example.prompt}
|
||||||
connected diagram for data ingestion
|
</p>
|
||||||
</p>
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src={example.src}
|
||||||
src="/rag_prod.svg"
|
alt={t.examples[example.id].alt}
|
||||||
alt="RAG Architecture Diagram"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="max-w-full max-h-full object-contain"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Authentication using React and AWS
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate
|
|
||||||
authentication process using React with{" "}
|
|
||||||
<strong>AWS</strong>. Use Serverless
|
|
||||||
architecture.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/auth.svg"
|
|
||||||
alt="Authentication Architecture Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Agile Scrum Process
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Generate agile
|
|
||||||
scrum workflow diagram for software
|
|
||||||
development team.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/agile_scrum.svg"
|
|
||||||
alt="Agile Scrum Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Open Innovation
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
|
||||||
<strong>Prompt:</strong> Create
|
|
||||||
visualization of Henry Chesbrough's
|
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
|
||||||
<Image
|
|
||||||
src="/inno.svg"
|
|
||||||
alt="Open Innovation Diagram"
|
|
||||||
width={480}
|
|
||||||
height={360}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
How It Works
|
{t.howHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 mb-4">
|
<p className="text-gray-700 mb-4">{t.howIntro}</p>
|
||||||
The application uses the following technologies:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
<ul className="list-disc pl-6 text-gray-700 space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<strong>Next.js</strong>: For the frontend framework
|
<strong>Next.js</strong>
|
||||||
and routing
|
{t.colon}
|
||||||
|
{t.tech.nextjs}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
|
<strong>Vercel AI SDK</strong>
|
||||||
<code>@ai-sdk/*</code>): For streaming AI responses
|
{t.parenOpen}
|
||||||
and multi-provider support
|
<code>ai</code> + <code>@ai-sdk/*</code>
|
||||||
|
{t.parenClose}
|
||||||
|
{t.colon}
|
||||||
|
{t.tech.aiSdk}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>draw.io</strong>: The editor, bundled with
|
<strong>draw.io</strong>
|
||||||
the app so it can be driven directly
|
{t.colon}
|
||||||
|
{t.tech.drawio}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">{t.howOutro}</p>
|
||||||
Diagrams are represented as XML that can be rendered in
|
|
||||||
draw.io. The AI processes your commands and generates or
|
|
||||||
modifies this XML accordingly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Multi-Provider Support */}
|
{/* Multi-Provider Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Multi-Provider Support
|
{t.providersHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>ByteDance Doubao</li>
|
<li>{t.doubao}</li>
|
||||||
<li>AWS Bedrock (default)</li>
|
<li>{t.bedrock}</li>
|
||||||
<li>
|
<li>
|
||||||
OpenAI / OpenAI-compatible APIs (via{" "}
|
{t.openai.before}
|
||||||
<code>OPENAI_BASE_URL</code>)
|
<code>OPENAI_BASE_URL</code>
|
||||||
|
{t.openai.after}
|
||||||
</li>
|
</li>
|
||||||
<li>Anthropic</li>
|
<li>Anthropic</li>
|
||||||
<li>Google AI</li>
|
<li>Google AI</li>
|
||||||
@@ -303,48 +602,47 @@ export default function About() {
|
|||||||
<li>ModelScope</li>
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
{t.modelNote.before}
|
||||||
draw.io diagrams with AWS logos, so if you want to
|
<code>claude-sonnet-4-5</code>
|
||||||
create AWS architecture diagrams, this is the best
|
{t.modelNote.after}
|
||||||
choice.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
Support & Contact
|
{t.supportHeading}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you find this project useful, please consider{" "}
|
{t.sponsor.before}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/sponsors/DayuanJiang"
|
href="https://github.com/sponsors/DayuanJiang"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
sponsoring
|
{t.sponsor.link}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to help host the live demo site!
|
{t.sponsor.after}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 mt-2">
|
<p className="text-gray-700 mt-2">
|
||||||
For support or inquiries, please open an issue on the{" "}
|
{t.contact.before}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
GitHub repository
|
{t.contact.link}
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
or contact: me[at]jiang.jp
|
{t.contact.after}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="mt-12 text-center">
|
<div className="mt-12 text-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={`/${lang}`}
|
||||||
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Open Editor
|
{t.openEditor}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -354,8 +652,7 @@ export default function About() {
|
|||||||
<footer className="bg-white border-t border-gray-200 mt-16">
|
<footer className="bg-white border-t border-gray-200 mt-16">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<p className="text-center text-gray-600 text-sm">
|
<p className="text-center text-gray-600 text-sm">
|
||||||
Next AI Draw.io - Open Source AI-Powered Diagram
|
{t.footer}
|
||||||
Generator
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
|
||||||
|
import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
import type { ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
|||||||
|
|
||||||
// ── Shared types ─────────────────────────────────────────────────────
|
// ── Shared types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SecretValue = { isSet: true; hint: string }
|
// The same shapes the admin API validates and returns. Secret fields of a
|
||||||
|
// provider hold either a masked marker (unchanged) or a plaintext string
|
||||||
|
// (new value).
|
||||||
|
export type { AdminProvider, SecretValue }
|
||||||
|
|
||||||
export function isSecretValue(v: unknown): v is SecretValue {
|
export function isSecretValue(v: unknown): v is SecretValue {
|
||||||
return typeof v === "object" && v !== null && "isSet" in v
|
return typeof v === "object" && v !== null && "isSet" in v
|
||||||
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
|
|||||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin provider in client state. Secret fields hold either a masked
|
|
||||||
// marker (unchanged) or a plaintext string (new value).
|
|
||||||
export interface AdminProvider {
|
|
||||||
id: string
|
|
||||||
provider: ProviderName
|
|
||||||
name?: string
|
|
||||||
apiKey?: string | SecretValue
|
|
||||||
baseUrl?: string
|
|
||||||
awsAccessKeyId?: string | SecretValue
|
|
||||||
awsSecretAccessKey?: string | SecretValue
|
|
||||||
awsRegion?: string
|
|
||||||
vertexApiKey?: string | SecretValue
|
|
||||||
models: string[]
|
|
||||||
isDefault?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||||
export interface EnvProvider {
|
export interface EnvProvider {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import { useState } from "react"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||||
@@ -206,26 +199,6 @@ export function SettingField({
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "enum":
|
|
||||||
control = (
|
|
||||||
<Select
|
|
||||||
value={currentValue || undefined}
|
|
||||||
disabled={disabled}
|
|
||||||
onValueChange={onChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
|
||||||
<SelectValue placeholder={dict.admin.notSet} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{def.options?.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case "secret":
|
case "secret":
|
||||||
control = (
|
control = (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
|||||||
+17
-17
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
|
|||||||
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||||
@@ -40,6 +41,13 @@ export async function generateStaticParams() {
|
|||||||
return i18n.locales.map((locale) => ({ lang: locale }))
|
return i18n.locales.map((locale) => ({ lang: locale }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OG_LOCALE: Record<Locale, string> = {
|
||||||
|
en: "en_US",
|
||||||
|
zh: "zh_CN",
|
||||||
|
ja: "ja_JP",
|
||||||
|
"zh-Hant": "zh_HK",
|
||||||
|
}
|
||||||
|
|
||||||
// Generate metadata per locale
|
// Generate metadata per locale
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -47,22 +55,19 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ lang: string }>
|
params: Promise<{ lang: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { lang: rawLang } = await params
|
const { lang: rawLang } = await params
|
||||||
const lang = (
|
const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
|
||||||
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
|
|
||||||
) as Locale
|
|
||||||
|
|
||||||
// Default to English metadata
|
|
||||||
const titles: Record<Locale, string> = {
|
const titles: Record<Locale, string> = {
|
||||||
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
en: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||||
zh: "Next AI Draw.io - AI powered diagram generator",
|
zh: "Next AI Draw.io - AI 驱动的图表生成器",
|
||||||
ja: "Next AI Draw.io - AI-powered diagram generator",
|
ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
|
||||||
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptions: Record<Locale, string> = {
|
const descriptions: Record<Locale, string> = {
|
||||||
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||||
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
|
zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
|
||||||
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
|
ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
|
||||||
"zh-Hant":
|
"zh-Hant":
|
||||||
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
|
||||||
}
|
}
|
||||||
@@ -91,14 +96,7 @@ export async function generateMetadata({
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: "https://next-ai-drawio.jiang.jp",
|
url: "https://next-ai-drawio.jiang.jp",
|
||||||
siteName: "Next AI Draw.io",
|
siteName: "Next AI Draw.io",
|
||||||
locale:
|
locale: OG_LOCALE[lang],
|
||||||
lang === "zh"
|
|
||||||
? "zh_CN"
|
|
||||||
: lang === "zh-Hant"
|
|
||||||
? "zh_HK"
|
|
||||||
: lang === "ja"
|
|
||||||
? "ja_JP"
|
|
||||||
: "en_US",
|
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/architecture.png",
|
url: "/architecture.png",
|
||||||
@@ -184,7 +182,9 @@ export default async function RootLayout({
|
|||||||
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DictionaryProvider dictionary={dictionary}>
|
<DictionaryProvider dictionary={dictionary}>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<LocaleProvider locale={validLang}>
|
||||||
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
</LocaleProvider>
|
||||||
</DictionaryProvider>
|
</DictionaryProvider>
|
||||||
<AppToaster />
|
<AppToaster />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
|
|||||||
return value === "true" || value === "false"
|
return value === "true" || value === "false"
|
||||||
? null
|
? null
|
||||||
: 'Must be "true" or "false"'
|
: 'Must be "true" or "false"'
|
||||||
case "enum":
|
|
||||||
return def.options?.includes(value)
|
|
||||||
? null
|
|
||||||
: `Must be one of: ${def.options?.join(", ")}`
|
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||||
import {
|
import {
|
||||||
AdminProviderSchema,
|
AdminProviderSchema,
|
||||||
@@ -6,6 +5,7 @@ import {
|
|||||||
mergeSecrets,
|
mergeSecrets,
|
||||||
} from "@/lib/admin/providers"
|
} from "@/lib/admin/providers"
|
||||||
import { globalBaseUrl } from "@/lib/ai-providers"
|
import { globalBaseUrl } from "@/lib/ai-providers"
|
||||||
|
import { testModelConnection } from "@/lib/test-model-connection"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
|
|||||||
sameEndpoint && stored ? [stored] : [],
|
sameEndpoint && stored ? [stored] : [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// The admin's own request supplies the EdgeOne endpoint, cookies and
|
||||||
|
// access code, as a chat request would.
|
||||||
const serverUrl = globalBaseUrl(resolved.provider)
|
const serverUrl = globalBaseUrl(resolved.provider)
|
||||||
return validateModel(
|
return testModelConnection(
|
||||||
new Request(new URL("/api/validate-model", req.url), {
|
req,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
provider: resolved.provider,
|
||||||
"Content-Type": "application/json",
|
apiKey: resolved.apiKey,
|
||||||
// Checked again there, in place of an access code
|
baseUrl: resolved.baseUrl || serverUrl,
|
||||||
"x-admin-password": req.headers.get("x-admin-password") || "",
|
modelId: body.modelId,
|
||||||
// The EdgeOne function checks the access code and Pages
|
awsAccessKeyId: resolved.awsAccessKeyId,
|
||||||
// cookies, and its URL is built from the page's origin
|
awsSecretAccessKey: resolved.awsSecretAccessKey,
|
||||||
"x-access-code": req.headers.get("x-access-code") || "",
|
awsRegion: resolved.awsRegion,
|
||||||
cookie: req.headers.get("cookie") || "",
|
vertexApiKey: resolved.vertexApiKey,
|
||||||
...(req.headers.get("origin") && {
|
},
|
||||||
origin: req.headers.get("origin") as string,
|
!resolved.baseUrl && !!serverUrl,
|
||||||
}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
provider: resolved.provider,
|
|
||||||
apiKey: resolved.apiKey,
|
|
||||||
// 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,
|
|
||||||
awsRegion: resolved.awsRegion,
|
|
||||||
vertexApiKey: resolved.vertexApiKey,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-318
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
APICallError,
|
APICallError,
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
|
||||||
createUIMessageStreamResponse,
|
|
||||||
InvalidToolInputError,
|
InvalidToolInputError,
|
||||||
|
isFileUIPart,
|
||||||
|
isTextUIPart,
|
||||||
|
pruneMessages,
|
||||||
|
safeValidateUIMessages,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
streamText,
|
streamText,
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { jsonrepair } from "jsonrepair"
|
import { jsonrepair } from "jsonrepair"
|
||||||
import path from "path"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import {
|
import {
|
||||||
CACHE_POINT,
|
CACHE_POINT,
|
||||||
@@ -21,14 +21,14 @@ import {
|
|||||||
usesServerCredentials,
|
usesServerCredentials,
|
||||||
usesServerEndpoint,
|
usesServerEndpoint,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
|
||||||
import {
|
import {
|
||||||
dropInvalidToolCalls,
|
dropInvalidToolCalls,
|
||||||
fixToolInputJson,
|
fixToolInputJson,
|
||||||
formatSelectionContext,
|
formatSelectionContext,
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
validateFileParts,
|
||||||
} from "@/lib/chat-helpers"
|
} from "@/lib/chat-helpers"
|
||||||
|
import type { ChatUIMessage } from "@/lib/chat-message"
|
||||||
|
import { chatTools } from "@/lib/chat-tools"
|
||||||
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
@@ -56,50 +56,11 @@ import { getSystemPrompt } from "@/lib/system-prompts"
|
|||||||
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
import { normalizeBaseUrl } from "@/lib/types/model-config"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
|
|
||||||
import {
|
|
||||||
getShapeLibrary,
|
|
||||||
SHAPE_LIBRARY_LIST,
|
|
||||||
} from "@/packages/mcp-server/src/shape-library.ts"
|
|
||||||
import {
|
|
||||||
STYLE_CLASS_EXAMPLE,
|
|
||||||
SWIMLANE_EXAMPLE,
|
|
||||||
} from "@/packages/mcp-server/src/xml-examples.ts"
|
|
||||||
|
|
||||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
// 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,
|
// 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.
|
// 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 {
|
|
||||||
const toolCallId = `cached-${Date.now()}`
|
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
|
||||||
execute: async ({ writer }) => {
|
|
||||||
writer.write({ type: "start" })
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-start",
|
|
||||||
toolCallId,
|
|
||||||
toolName: "display_diagram",
|
|
||||||
})
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-delta",
|
|
||||||
toolCallId,
|
|
||||||
inputTextDelta: xml,
|
|
||||||
})
|
|
||||||
writer.write({
|
|
||||||
type: "tool-input-available",
|
|
||||||
toolCallId,
|
|
||||||
toolName: "display_diagram",
|
|
||||||
input: { xml },
|
|
||||||
})
|
|
||||||
writer.write({ type: "finish" })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return createUIMessageStreamResponse({ stream })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||||
const modelStreamResponses = new WeakSet<Response>()
|
const modelStreamResponses = new WeakSet<Response>()
|
||||||
// A Request's signal follows the client's disconnect only while the Request
|
// A Request's signal follows the client's disconnect only while the Request
|
||||||
@@ -111,6 +72,21 @@ const requestOfResponse = new WeakMap<Response, Request>()
|
|||||||
// Inner handler function
|
// Inner handler function
|
||||||
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
|
||||||
|
|
||||||
|
// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
|
||||||
|
// and input keys, without the diagram XML or image data
|
||||||
|
const describeMessages = (msgs: any[]) =>
|
||||||
|
msgs.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
parts: (m.parts ?? m.content ?? []).map(
|
||||||
|
(p: any) =>
|
||||||
|
p.type +
|
||||||
|
(p.toolName ? `:${p.toolName}` : "") +
|
||||||
|
(p.input && typeof p.input === "object"
|
||||||
|
? `(${Object.keys(p.input).join(",")})`
|
||||||
|
: ""),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
@@ -119,7 +95,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
if (accessDenied) return accessDenied
|
if (accessDenied) return accessDenied
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { messages, xml, previousXml, sessionId } = body
|
// The messages in the SDK's UI shape. The route writes its own system
|
||||||
|
// messages, so none may come from the client.
|
||||||
|
const validated = await safeValidateUIMessages<ChatUIMessage>({
|
||||||
|
messages: body.messages,
|
||||||
|
})
|
||||||
|
if (!validated.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: validated.error.message },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const messages = validated.data.filter((m) => m.role !== "system")
|
||||||
|
const { xml, previousXml, sessionId } = body
|
||||||
const customSystemMessage =
|
const customSystemMessage =
|
||||||
typeof body.customSystemMessage === "string"
|
typeof body.customSystemMessage === "string"
|
||||||
? body.customSystemMessage.slice(0, 5000)
|
? body.customSystemMessage.slice(0, 5000)
|
||||||
@@ -138,9 +126,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
|
||||||
const lastUserMessage = [...messages]
|
const lastUserMessage = [...messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((m: any) => m.role === "user")
|
.find((m) => m.role === "user")
|
||||||
const userInputText =
|
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
|
||||||
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
|
|
||||||
|
|
||||||
// Update Langfuse trace with input, session, and user
|
// Update Langfuse trace with input, session, and user
|
||||||
setTraceInput({
|
setTraceInput({
|
||||||
@@ -156,23 +143,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
// === FILE VALIDATION END ===
|
// === FILE VALIDATION END ===
|
||||||
|
|
||||||
// === CACHE CHECK START ===
|
|
||||||
const isFirstMessage = messages.length === 1
|
|
||||||
const isEmptyDiagram = !xml || !hasCells(xml)
|
|
||||||
|
|
||||||
if (isFirstMessage && isEmptyDiagram) {
|
|
||||||
const lastMessage = messages[0]
|
|
||||||
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
|
|
||||||
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
|
|
||||||
|
|
||||||
const cached = findCachedResponse(textPart?.text || "", !!filePart)
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return createCachedStreamResponse(cached.xml)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// === CACHE CHECK END ===
|
|
||||||
|
|
||||||
// Read client AI provider overrides from headers
|
// Read client AI provider overrides from headers
|
||||||
const provider = req.headers.get("x-ai-provider")
|
const provider = req.headers.get("x-ai-provider")
|
||||||
let baseUrl = req.headers.get("x-ai-base-url")
|
let baseUrl = req.headers.get("x-ai-base-url")
|
||||||
@@ -348,16 +318,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// System prompt, with the model name filled in and the style section chosen
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
const finalSystemMessage = customSystemMessage
|
const finalSystemMessage = customSystemMessage
|
||||||
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||||
: systemMessage
|
: systemMessage
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
|
||||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
|
||||||
[]
|
|
||||||
|
|
||||||
// Note: we used to pre-emptively reject images for models we guessed were
|
// Note: we used to pre-emptively reject images for models we guessed were
|
||||||
// text-only (by name matching). That heuristic misfired on newer models
|
// text-only (by name matching). That heuristic misfired on newer models
|
||||||
@@ -370,98 +338,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
${userInputText}
|
${userInputText}
|
||||||
"""`
|
"""`
|
||||||
|
|
||||||
// Convert UIMessages to ModelMessages and add system message. A tool
|
// Convert UIMessages to ModelMessages. A tool call that never got its
|
||||||
// call that never got its result (the user stopped while it ran) is
|
// result (the user stopped while it ran) is left out: the SDK would
|
||||||
// left out: the SDK would refuse this and every later request of the
|
// refuse this and every later request of the chat
|
||||||
// chat (MissingToolResultsError)
|
// (MissingToolResultsError). Messages left without content (a notice
|
||||||
const modelMessages = await convertToModelMessages(messages, {
|
// the chat shows is a data part, which the model never sees) are
|
||||||
ignoreIncompleteToolCalls: true,
|
// dropped: Bedrock rejects empty content.
|
||||||
|
const modelMessages = pruneMessages({
|
||||||
|
messages: await convertToModelMessages(messages, {
|
||||||
|
ignoreIncompleteToolCalls: true,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Incoming messages count:", messages.length)
|
console.log(
|
||||||
messages.forEach((msg: any, idx: number) => {
|
"[route.ts] incoming",
|
||||||
console.log(
|
JSON.stringify(describeMessages(messages)),
|
||||||
`[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 =
|
|
||||||
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
|
|
||||||
const placeholderMessages = enableHistoryReplace
|
|
||||||
? replaceHistoricalToolInputs(modelMessages)
|
|
||||||
: modelMessages
|
|
||||||
|
|
||||||
// Filter out messages with empty content arrays (Bedrock API rejects these)
|
|
||||||
// This is a safety measure - ideally convertToModelMessages should handle all cases
|
|
||||||
let enhancedMessages = placeholderMessages.filter(
|
|
||||||
(msg: any) =>
|
|
||||||
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
// 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.
|
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
let enhancedMessages = dropInvalidToolCalls(modelMessages)
|
||||||
|
|
||||||
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
|
||||||
if (DEBUG_LLM_PAYLOAD) {
|
if (DEBUG_LLM_PAYLOAD) {
|
||||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
console.log(
|
||||||
enhancedMessages.forEach((msg: any, idx: number) => {
|
"[route.ts] to model",
|
||||||
console.log(
|
JSON.stringify(describeMessages(enhancedMessages)),
|
||||||
`[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)
|
// Update the last message with user input only (XML moved to separate cached system message)
|
||||||
@@ -566,13 +473,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
|
|
||||||
const allMessages = [...systemMessages, ...enhancedMessages]
|
const allMessages = [...systemMessages, ...enhancedMessages]
|
||||||
|
|
||||||
// Set by onAbort, which records the finished steps' tokens itself
|
|
||||||
let stopped = false
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model,
|
model,
|
||||||
// The system messages carry cache points, so they go in messages.
|
// The system messages carry cache points, so they go in messages
|
||||||
// A client's own system messages have string content and were
|
// (the client's own system messages were dropped above)
|
||||||
// dropped by the empty-content filter above.
|
|
||||||
allowSystemInMessages: true,
|
allowSystemInMessages: true,
|
||||||
abortSignal: req.signal,
|
abortSignal: req.signal,
|
||||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||||
@@ -619,166 +523,31 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
|||||||
},
|
},
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
|
||||||
// Langfuse telemetry config (returns undefined if not configured)
|
// Langfuse telemetry (undefined when not configured)
|
||||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
experimental_telemetry: getTelemetryConfig({
|
||||||
experimental_telemetry: getTelemetryConfig({
|
sessionId: validSessionId,
|
||||||
sessionId: validSessionId,
|
userId,
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
onFinish: ({ text, totalUsage }) => {
|
// The quota counts each step as it ends: a later step that fails or
|
||||||
|
// is stopped has still used these tokens (onFinish runs only for a
|
||||||
|
// completed answer). Awaited, so the write is done before the host
|
||||||
|
// may freeze the function after the answer ends.
|
||||||
|
onStepFinish: async ({ usage }) => {
|
||||||
|
if (countsQuota) {
|
||||||
|
await recordTokenUsage(userId, usage.totalTokens ?? 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinish: ({ text }) => {
|
||||||
// AI SDK 6 telemetry auto-reports token usage on its spans
|
// AI SDK 6 telemetry auto-reports token usage on its spans
|
||||||
setTraceOutput(text)
|
setTraceOutput(text)
|
||||||
|
|
||||||
// Record token usage for server-side quota tracking (if enabled)
|
|
||||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
|
||||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
|
||||||
if (countsQuota && totalUsage && !stopped) {
|
|
||||||
const totalTokens =
|
|
||||||
(totalUsage.inputTokens || 0) +
|
|
||||||
(totalUsage.outputTokens || 0)
|
|
||||||
recordTokenUsage(userId, totalTokens)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||||
onError: ({ error }) => {
|
onError: ({ error }) => {
|
||||||
console.error(error) // what AI SDK does without an onError
|
console.error(error) // what AI SDK does without an onError
|
||||||
endTrace()
|
endTrace()
|
||||||
},
|
},
|
||||||
onAbort: ({ steps }) => {
|
onAbort: () => endTrace(),
|
||||||
stopped = true
|
tools: chatTools,
|
||||||
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: {
|
|
||||||
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
|
|
||||||
|
|
||||||
VALIDATION RULES (XML will be rejected if violated):
|
|
||||||
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
|
||||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
|
||||||
3. All mxCell elements must be siblings - never nested
|
|
||||||
4. Every mxCell needs a unique id (start from "2")
|
|
||||||
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
|
|
||||||
6. Escape special chars in values: < > & "
|
|
||||||
|
|
||||||
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
|
|
||||||
|
|
||||||
Example (generate ONLY this - no wrapper tags):
|
|
||||||
${SWIMLANE_EXAMPLE}
|
|
||||||
|
|
||||||
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use <br> for a line break, never \\n; a literal < or > is written &lt; or &gt;.
|
|
||||||
${STYLE_CLASS_EXAMPLE}
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- For AWS diagrams, use **AWS 2025 icons**.
|
|
||||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
|
||||||
`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
xml: z
|
|
||||||
.string()
|
|
||||||
.describe("XML string to be displayed on draw.io"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
edit_diagram: {
|
|
||||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
|
||||||
|
|
||||||
Operations:
|
|
||||||
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
|
|
||||||
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
|
||||||
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
|
||||||
|
|
||||||
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
|
|
||||||
|
|
||||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
|
||||||
|
|
||||||
Example - Add a rectangle:
|
|
||||||
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
|
|
||||||
|
|
||||||
Example - Delete container (children & edges auto-deleted):
|
|
||||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
operations: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
operation: z
|
|
||||||
.enum(["update", "add", "delete"])
|
|
||||||
.describe(
|
|
||||||
"Operation to perform: add, update, or delete",
|
|
||||||
),
|
|
||||||
cell_id: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"The id of the mxCell. Must match the id attribute in new_xml.",
|
|
||||||
),
|
|
||||||
new_xml: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Complete mxCell XML element (required for update/add)",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.describe("Array of operations to apply"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
append_diagram: {
|
|
||||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
|
||||||
|
|
||||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
|
||||||
|
|
||||||
CRITICAL INSTRUCTIONS:
|
|
||||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
|
||||||
2. Continue from EXACTLY where your previous output stopped
|
|
||||||
3. Complete the remaining mxCell elements
|
|
||||||
4. If still truncated, call append_diagram again with the next fragment
|
|
||||||
|
|
||||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
xml: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Continuation XML fragment to append (NO wrapper tags)",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
get_shape_library: {
|
|
||||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
|
||||||
|
|
||||||
Available libraries:
|
|
||||||
${SHAPE_LIBRARY_LIST}
|
|
||||||
|
|
||||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
|
||||||
inputSchema: z.object({
|
|
||||||
library: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ library }) => {
|
|
||||||
// Only known library names reach the file system
|
|
||||||
const result = await getShapeLibrary(
|
|
||||||
library,
|
|
||||||
path.join(process.cwd(), "docs/shape-libraries"),
|
|
||||||
)
|
|
||||||
return result.ok ? result.text : result.error
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(process.env.TEMPERATURE !== undefined && {
|
...(process.env.TEMPERATURE !== undefined && {
|
||||||
temperature: parseFloat(process.env.TEMPERATURE),
|
temperature: parseFloat(process.env.TEMPERATURE),
|
||||||
}),
|
}),
|
||||||
@@ -790,17 +559,6 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
|||||||
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
// Keyless endpoints keep theirs: the desktop app's Ollama is the
|
||||||
// user's own, and EdgeOne's text is our function's explanation.
|
// user's own, and EdgeOne's text is our function's explanation.
|
||||||
onError: (error) => streamErrorText(error, onServerCredentials),
|
onError: (error) => streamErrorText(error, onServerCredentials),
|
||||||
messageMetadata: ({ part }) => {
|
|
||||||
if (part.type === "finish") {
|
|
||||||
const usage = (part as any).totalUsage
|
|
||||||
// AI SDK 6 provides totalTokens directly
|
|
||||||
return {
|
|
||||||
totalTokens: usage?.totalTokens ?? 0,
|
|
||||||
finishReason: (part as any).finishReason,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
modelStreamResponses.add(response)
|
modelStreamResponses.add(response)
|
||||||
requestOfResponse.set(response, req)
|
requestOfResponse.set(response, req)
|
||||||
@@ -840,7 +598,7 @@ async function safeHandler(req: Request): Promise<Response> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
response = handleError(error)
|
response = handleError(error)
|
||||||
}
|
}
|
||||||
// Early returns, cache hits and errors never reach streamText's callbacks,
|
// Early returns and errors never reach streamText's callbacks,
|
||||||
// so their Langfuse trace has to be ended here
|
// so their Langfuse trace has to be ended here
|
||||||
if (!modelStreamResponses.has(response)) endTrace()
|
if (!modelStreamResponses.has(response)) endTrace()
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
# Draw.io XML Schema Guide
|
|
||||||
|
|
||||||
This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
|
|
||||||
|
|
||||||
## Basic Structure
|
|
||||||
|
|
||||||
A draw.io XML file has the following hierarchy:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxfile>
|
|
||||||
<diagram>
|
|
||||||
<mxGraphModel>
|
|
||||||
<root>
|
|
||||||
<mxCell /> <!-- Cells that make up the diagram -->
|
|
||||||
</root>
|
|
||||||
</mxGraphModel>
|
|
||||||
</diagram>
|
|
||||||
</mxfile>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Element: `<mxfile>`
|
|
||||||
|
|
||||||
The root element of a draw.io file.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `host`: The application that created the file (e.g., "app.diagrams.net")
|
|
||||||
- `modified`: Last modification timestamp
|
|
||||||
- `agent`: Browser / user agent information
|
|
||||||
- `version`: Version of the application
|
|
||||||
- `type`: File type (usually "device" or "google")
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxfile host="app.diagrams.net" modified="2023-07-14T10:20:30.123Z" agent="Mozilla/5.0" version="21.5.2" type="device">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Diagram Element: `<diagram>`
|
|
||||||
|
|
||||||
Each page in your draw.io document is represented by a `<diagram>` element.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `id`: Unique identifier for the diagram
|
|
||||||
- `name`: The name of the diagram / page
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<diagram id="pWHN0msd4Ud1ZK5cD-Hr" name="Page-1">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graph Model: `<mxGraphModel>`
|
|
||||||
|
|
||||||
Contains the actual diagram data.
|
|
||||||
|
|
||||||
**Attributes:**
|
|
||||||
|
|
||||||
- `dx`: Grid size in x-direction (usually 1)
|
|
||||||
- `dy`: Grid size in y-direction (usually 1)
|
|
||||||
- `grid`: Whether grid is enabled (0 or 1)
|
|
||||||
- `gridSize`: Grid cell size (usually 10)
|
|
||||||
- `guides`: Whether guides are enabled (0 or 1)
|
|
||||||
- `tooltips`: Whether tooltips are enabled (0 or 1)
|
|
||||||
- `connect`: Whether connections are enabled (0 or 1)
|
|
||||||
- `arrows`: Whether arrows are enabled (0 or 1)
|
|
||||||
- `fold`: Whether folding is enabled (0 or 1)
|
|
||||||
- `page`: Whether page view is enabled (0 or 1)
|
|
||||||
- `pageScale`: Scale of the page (usually 1)
|
|
||||||
- `pageWidth`: Width of the page (e.g., 850)
|
|
||||||
- `pageHeight`: Height of the page (e.g., 1100)
|
|
||||||
- `math`: Whether math typesetting is enabled (0 or 1)
|
|
||||||
- `shadow`: Whether shadows are enabled (0 or 1)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGraphModel dx="1" dy="1" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Cell Container: `<root>`
|
|
||||||
|
|
||||||
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
|
|
||||||
|
|
||||||
**Internal structure (auto-generated):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<root>
|
|
||||||
<mxCell id="0"/> <!-- Auto-added -->
|
|
||||||
<mxCell id="1" parent="0"/> <!-- Auto-added -->
|
|
||||||
<!-- Your mxCell elements go here (start from id="2") -->
|
|
||||||
</root>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cell Elements: `<mxCell>`
|
|
||||||
|
|
||||||
The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
|
|
||||||
|
|
||||||
**Attributes for all cells:**
|
|
||||||
|
|
||||||
- `id`: Unique identifier for the cell
|
|
||||||
- `parent`: ID of the parent cell (typically "1" for most cells)
|
|
||||||
- `value`: Text content of the cell
|
|
||||||
- `style`: Styling information (see Style section below)
|
|
||||||
|
|
||||||
**Attributes for shapes (vertices):**
|
|
||||||
|
|
||||||
- `vertex`: Set to "1" for shapes
|
|
||||||
- `connectable`: Whether the shape can be connected (0 or 1)
|
|
||||||
|
|
||||||
**Attributes for connectors (edges):**
|
|
||||||
|
|
||||||
- `edge`: Set to "1" for connectors
|
|
||||||
- `source`: ID of the source cell
|
|
||||||
- `target`: ID of the target cell
|
|
||||||
|
|
||||||
**Example (Rectangle shape):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="2" value="Hello World" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example (Connector):**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="3" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="2" target="4">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
|
||||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Geometry: `<mxGeometry>`
|
|
||||||
|
|
||||||
Defines the position and dimensions of cells.
|
|
||||||
|
|
||||||
**Attributes for shapes:**
|
|
||||||
|
|
||||||
- `x`: The x-coordinate of the **top-left** point of the shape.
|
|
||||||
- `y`: The y-coordinate of the **top-left** point of the shape.
|
|
||||||
- `width`: The width of the shape.
|
|
||||||
- `height`: The height of the shape.
|
|
||||||
- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
|
|
||||||
|
|
||||||
**Attributes for connectors:**
|
|
||||||
|
|
||||||
- `relative`: Set to "1" for relative geometry
|
|
||||||
- `as`: Set to "geometry"
|
|
||||||
|
|
||||||
**Example for shapes:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example for connectors:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
|
||||||
<mxPoint x="400" y="430" as="sourcePoint"/>
|
|
||||||
<mxPoint x="450" y="380" as="targetPoint"/>
|
|
||||||
</mxGeometry>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cell Style Reference
|
|
||||||
|
|
||||||
Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.
|
|
||||||
|
|
||||||
### Shape-specific Styles
|
|
||||||
|
|
||||||
- Rectangle: `shape=rectangle`
|
|
||||||
- Ellipse: `shape=ellipse`
|
|
||||||
- Triangle: `shape=triangle`
|
|
||||||
- Rhombus: `shape=rhombus`
|
|
||||||
- Hexagon: `shape=hexagon`
|
|
||||||
- Cloud: `shape=cloud`
|
|
||||||
- Actor: `shape=actor`
|
|
||||||
- Cylinder: `shape=cylinder`
|
|
||||||
- Document: `shape=document`
|
|
||||||
- Note: `shape=note`
|
|
||||||
- Card: `shape=card`
|
|
||||||
- Parallelogram: `shape=parallelogram`
|
|
||||||
|
|
||||||
### Connector Styles
|
|
||||||
|
|
||||||
- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
|
|
||||||
- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
|
|
||||||
- `curved=1`: Curved connector (0 or 1)
|
|
||||||
- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
|
|
||||||
- `elbow=vertical`: Elbow direction (vertical, horizontal)
|
|
||||||
- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
|
|
||||||
- `jumpSize=10`: Size of the jump
|
|
||||||
|
|
||||||
## Special Cells
|
|
||||||
|
|
||||||
Draw.io files contain two special cells that are always present:
|
|
||||||
|
|
||||||
1. **Root Cell** (id = "0"): The parent of all cells
|
|
||||||
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
|
|
||||||
|
|
||||||
## Tips for Creating Draw.io XML
|
|
||||||
|
|
||||||
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
|
|
||||||
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
|
|
||||||
3. Assign unique and sequential IDs to all cells
|
|
||||||
4. Define parent relationships correctly (use parent="1" for top-level shapes)
|
|
||||||
5. Use `mxGeometry` elements to position shapes
|
|
||||||
6. For connectors, specify `source` and `target` attributes
|
|
||||||
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Grouping Elements
|
|
||||||
|
|
||||||
To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Group container -->
|
|
||||||
<mxCell id="10" value="Group" style="group" vertex="1" connectable="0" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="200" height="100" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<!-- Elements inside the group -->
|
|
||||||
<mxCell id="11" value="Element 1" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
|
||||||
<mxGeometry width="90" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="12" value="Element 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
|
|
||||||
<mxGeometry x="110" width="90" height="40" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Swimlanes
|
|
||||||
|
|
||||||
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<root>
|
|
||||||
<mxCell id="0"/>
|
|
||||||
<mxCell id="1" parent="0"/>
|
|
||||||
<!-- Swimlane 1 -->
|
|
||||||
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Swimlane 2 -->
|
|
||||||
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Step inside lane1 (parent="lane1") -->
|
|
||||||
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Step inside lane2 (parent="lane2") -->
|
|
||||||
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
|
|
||||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
|
|
||||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
|
||||||
<mxGeometry relative="1" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
</root>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tables
|
|
||||||
|
|
||||||
Tables use multiple cells with parent-child relationships:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="30" value="Table" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="180" height="120" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="31" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="30">
|
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### Custom Attributes
|
|
||||||
|
|
||||||
Draw.io allows adding custom attributes to cells:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="40" value="Custom Element" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="200" y="200" width="120" height="60" as="geometry"/>
|
|
||||||
<Object label="Custom Label" customAttr="value" />
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
These custom attributes can store additional metadata or be used by plugins and custom behaviors.
|
|
||||||
|
|
||||||
### User-defined Styles
|
|
||||||
|
|
||||||
You can define custom styles for cells by combining various style attributes:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<mxCell id="50" value="Custom Styled Cell"
|
|
||||||
style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1"
|
|
||||||
vertex="1" parent="1">
|
|
||||||
<mxGeometry x="300" y="200" width="120" height="80" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layers
|
|
||||||
|
|
||||||
You can create multiple layers in a diagram to organize complex diagrams:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Default layer (always present) -->
|
|
||||||
<mxCell id="1" parent="0"/>
|
|
||||||
|
|
||||||
<!-- Additional custom layer -->
|
|
||||||
<mxCell id="60" value="Layer 2" style="locked=0;group=" parent="0"/>
|
|
||||||
|
|
||||||
<!-- Elements in Layer 2 -->
|
|
||||||
<mxCell id="61" value="Element in Layer 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="60">
|
|
||||||
<mxGeometry x="200" y="300" width="120" height="60" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
```
|
|
||||||
@@ -3,8 +3,5 @@ import { NextResponse } from "next/server"
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
|
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
|
||||||
dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,
|
|
||||||
dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,
|
|
||||||
tpmLimit: Number(process.env.TPM_LIMIT) || 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
const feedbackSchema = z.object({
|
||||||
@@ -37,75 +36,24 @@ export async function POST(req: Request) {
|
|||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the most recent chat trace for this session to attach the score to
|
// Score the session's latest chat trace; without one, a standalone
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
// feedback trace carries the score
|
||||||
|
await scoreLatestTrace(langfuse, {
|
||||||
sessionId,
|
sessionId,
|
||||||
limit: 1,
|
name: "user-feedback",
|
||||||
|
value: feedback === "good" ? 1 : 0,
|
||||||
|
comment: `User gave ${feedback} feedback`,
|
||||||
|
fallbackTrace: {
|
||||||
|
name: "user-feedback",
|
||||||
|
userId,
|
||||||
|
input: { messageId, feedback },
|
||||||
|
metadata: {
|
||||||
|
source: "feedback-button",
|
||||||
|
note: "standalone - no chat trace found",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const traces = tracesResponse.data || []
|
|
||||||
const latestTrace = traces[0]
|
|
||||||
|
|
||||||
if (!latestTrace) {
|
|
||||||
// No trace found for this session - create a standalone feedback trace
|
|
||||||
const traceId = randomUUID()
|
|
||||||
const timestamp = new Date().toISOString()
|
|
||||||
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "trace-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: traceId,
|
|
||||||
name: "user-feedback",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
input: { messageId, feedback },
|
|
||||||
metadata: {
|
|
||||||
source: "feedback-button",
|
|
||||||
note: "standalone - no chat trace found",
|
|
||||||
},
|
|
||||||
timestamp,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId,
|
|
||||||
name: "user-feedback",
|
|
||||||
value: feedback === "good" ? 1 : 0,
|
|
||||||
comment: `User gave ${feedback} feedback`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Attach score to the existing chat trace
|
|
||||||
const timestamp = new Date().toISOString()
|
|
||||||
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId: latestTrace.id,
|
|
||||||
name: "user-feedback",
|
|
||||||
value: feedback === "good" ? 1 : 0,
|
|
||||||
comment: `User gave ${feedback} feedback`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ success: true, logged: true })
|
return Response.json({ success: true, logged: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse feedback error:", error)
|
console.error("Langfuse feedback error:", error)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "crypto"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { getLangfuseClient } from "@/lib/langfuse"
|
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
|
||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString()
|
// Flag the session's latest chat trace as saved; a session without a
|
||||||
|
// trace (no chat yet) is skipped
|
||||||
// Find the most recent chat trace for this session to attach the save flag
|
const logged = await scoreLatestTrace(langfuse, {
|
||||||
const tracesResponse = await langfuse.api.trace.list({
|
|
||||||
sessionId,
|
sessionId,
|
||||||
limit: 1,
|
name: "diagram-saved",
|
||||||
|
value: 1,
|
||||||
|
comment: `User saved diagram as ${filename}.${format}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const traces = tracesResponse.data || []
|
return Response.json({ success: true, logged })
|
||||||
const latestTrace = traces[0]
|
|
||||||
|
|
||||||
if (latestTrace) {
|
|
||||||
// Add a score to the existing trace to flag that user saved
|
|
||||||
await langfuse.api.ingestion.batch({
|
|
||||||
batch: [
|
|
||||||
{
|
|
||||||
type: "score-create",
|
|
||||||
id: randomUUID(),
|
|
||||||
timestamp,
|
|
||||||
body: {
|
|
||||||
id: randomUUID(),
|
|
||||||
traceId: latestTrace.id,
|
|
||||||
name: "diagram-saved",
|
|
||||||
value: 1,
|
|
||||||
comment: `User saved diagram as ${filename}.${format}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// If no trace found, skip logging (user hasn't chatted yet)
|
|
||||||
|
|
||||||
return Response.json({ success: true, logged: !!latestTrace })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Langfuse save error:", error)
|
console.error("Langfuse save error:", error)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const models = await loadFlattenedServerModels()
|
const models = await loadFlattenedServerModels()
|
||||||
return NextResponse.json({
|
return NextResponse.json({ models })
|
||||||
models,
|
|
||||||
hasConfig: models.length > 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* API endpoint for VLM-based diagram validation.
|
* API endpoint for VLM-based diagram validation.
|
||||||
* Accepts a PNG image and streams validation results using useObject-compatible format.
|
* Accepts a PNG image and answers with the validation result as JSON.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Output, streamText } from "ai"
|
import { generateText, Output } from "ai"
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import { getValidationModel } from "@/lib/ai-providers"
|
import { getValidationModel } from "@/lib/ai-providers"
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
recordTokenUsage,
|
recordTokenUsage,
|
||||||
} from "@/lib/dynamo-quota-manager"
|
} from "@/lib/dynamo-quota-manager"
|
||||||
|
import { getTelemetryConfig } from "@/lib/langfuse"
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
import { getUserIdFromRequest } from "@/lib/user-id"
|
||||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||||
import {
|
import {
|
||||||
@@ -35,13 +36,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
|
|||||||
suggestions: [],
|
suggestions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A fixed result in the text format useObject reads */
|
|
||||||
function createStreamingResponse(result: ValidationResult): Response {
|
|
||||||
return new Response(JSON.stringify(result), {
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request): Promise<Response> {
|
export async function POST(req: Request): Promise<Response> {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
@@ -53,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
// Check if VLM validation is enabled (default: true)
|
// Check if VLM validation is enabled (default: true)
|
||||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||||
if (!enableValidation) {
|
if (!enableValidation) {
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: ValidateDiagramRequest = await req.json()
|
const body: ValidateDiagramRequest = await req.json()
|
||||||
@@ -123,7 +117,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
// Return valid if no vision model is configured
|
// Return valid if no vision model is configured
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
// Parse timeout with validation (minimum 1000ms, default 10000ms)
|
||||||
@@ -133,8 +127,7 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
|
||||||
) || 10000
|
) || 10000
|
||||||
|
|
||||||
// Stream the VLM response for useObject consumption
|
const { output, totalUsage } = await generateText({
|
||||||
const result = streamText({
|
|
||||||
model,
|
model,
|
||||||
output: Output.object({ schema: ValidationResultSchema }),
|
output: Output.object({ schema: ValidationResultSchema }),
|
||||||
system: VALIDATION_SYSTEM_PROMPT,
|
system: VALIDATION_SYSTEM_PROMPT,
|
||||||
@@ -154,24 +147,25 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1024,
|
||||||
abortSignal: AbortSignal.timeout(timeout),
|
// Ends with the request: the user pressed Stop, or left
|
||||||
onFinish: ({ output, totalUsage }) => {
|
abortSignal: req.signal,
|
||||||
if (countsQuota && totalUsage) {
|
timeout,
|
||||||
recordTokenUsage(
|
// Its usage and cost show in Langfuse next to the chat's
|
||||||
userId,
|
experimental_telemetry: getTelemetryConfig({
|
||||||
(totalUsage.inputTokens || 0) +
|
functionId: "validate-diagram",
|
||||||
(totalUsage.outputTokens || 0),
|
sessionId,
|
||||||
)
|
userId,
|
||||||
}
|
}),
|
||||||
if (sessionId && output) {
|
|
||||||
console.log(
|
|
||||||
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
if (countsQuota) {
|
||||||
return result.toTextStreamResponse()
|
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
|
||||||
|
}
|
||||||
|
if (sessionId) {
|
||||||
|
console.log(
|
||||||
|
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Response.json(output)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log with session context if available
|
// Log with session context if available
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -179,6 +173,6 @@ export async function POST(req: Request): Promise<Response> {
|
|||||||
console.error("[validate-diagram] Error:", errorMessage)
|
console.error("[validate-diagram] Error:", errorMessage)
|
||||||
|
|
||||||
// On error, return valid to not block the user
|
// On error, return valid to not block the user
|
||||||
return createStreamingResponse(DEFAULT_VALID_RESULT)
|
return Response.json(DEFAULT_VALID_RESULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-233
@@ -1,249 +1,29 @@
|
|||||||
import { streamText, tool } from "ai"
|
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { z } from "zod"
|
|
||||||
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
|
||||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
|
||||||
import {
|
import {
|
||||||
edgeOneEndpoint,
|
type TestModelRequest,
|
||||||
getAIModel,
|
testModelConnection,
|
||||||
globalBaseUrl,
|
} from "@/lib/test-model-connection"
|
||||||
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 { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
|
|
||||||
import { getUserIdFromRequest } from "@/lib/user-id"
|
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
interface ValidateRequest {
|
// The Settings dialog's Test button: a model on the user's own credentials
|
||||||
provider: ProviderName
|
|
||||||
apiKey: string
|
|
||||||
baseUrl?: string
|
|
||||||
modelId: string
|
|
||||||
// AWS Bedrock specific
|
|
||||||
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) {
|
export async function POST(req: Request) {
|
||||||
const crossSite = rejectCrossSite(req)
|
const crossSite = rejectCrossSite(req)
|
||||||
if (crossSite) return crossSite
|
if (crossSite) return crossSite
|
||||||
// Lets the server send requests to arbitrary URLs, so require the access
|
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||||
// code, or the admin password (the admin panel's Test button)
|
|
||||||
const accessError = checkAccessCode(req)
|
const accessError = checkAccessCode(req)
|
||||||
if (accessError && checkAdminAuth(req)) return accessError
|
if (accessError) return accessError
|
||||||
|
|
||||||
|
let body: TestModelRequest
|
||||||
try {
|
try {
|
||||||
const body: ValidateRequest = await req.json()
|
body = await req.json()
|
||||||
const {
|
} catch {
|
||||||
provider,
|
|
||||||
apiKey,
|
|
||||||
modelId,
|
|
||||||
awsAccessKeyId,
|
|
||||||
awsSecretAccessKey,
|
|
||||||
awsRegion,
|
|
||||||
awsSessionToken,
|
|
||||||
// Note: Express Mode only needs vertexApiKey
|
|
||||||
vertexApiKey,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!provider || !modelId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "Provider and model ID are required" },
|
|
||||||
{ 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 &&
|
|
||||||
!serverUrl &&
|
|
||||||
!allowPrivateUrls() &&
|
|
||||||
(await isPrivateUrl(baseUrl))
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "Invalid base URL" },
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate credentials based on provider
|
|
||||||
if (provider === "bedrock") {
|
|
||||||
if (
|
|
||||||
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
|
|
||||||
!awsRegion
|
|
||||||
) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider === "vertexai") {
|
|
||||||
if (!vertexApiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: false,
|
|
||||||
error: "Vertex AI API key is required for Express Mode",
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ valid: false, error: "API key is required" },
|
|
||||||
{ 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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
const { code, message } = classifyLLMError(error)
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, code, error: message },
|
{ valid: false, error: "Invalid JSON body" },
|
||||||
{ status: 200 }, // Return 200 so client can read error message
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// A URL in the request is one the user typed, never the server's own
|
||||||
|
return testModelConnection(req, body, false)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.0 KiB |
+9
-22
@@ -98,9 +98,7 @@
|
|||||||
--shadow-pop:
|
--shadow-pop:
|
||||||
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
|
||||||
|
|
||||||
--duration-normal: 200ms;
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -241,13 +239,6 @@
|
|||||||
.shadow-pop {
|
.shadow-pop {
|
||||||
box-shadow: var(--shadow-pop);
|
box-shadow: var(--shadow-pop);
|
||||||
}
|
}
|
||||||
/* Older names kept for components that still use them */
|
|
||||||
.shadow-soft {
|
|
||||||
box-shadow: var(--shadow-float);
|
|
||||||
}
|
|
||||||
.shadow-dialog {
|
|
||||||
box-shadow: var(--shadow-pop);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -284,17 +275,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-pop {
|
/* A highlight sweeps across "Thinking..." text (Shimmer) */
|
||||||
0% {
|
@keyframes shimmer {
|
||||||
transform: scale(0.8);
|
from {
|
||||||
opacity: 0;
|
background-position: 100% center;
|
||||||
}
|
}
|
||||||
50% {
|
to {
|
||||||
transform: scale(1.1);
|
background-position: 0% center;
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,15 +294,15 @@
|
|||||||
.animate-panel-in {
|
.animate-panel-in {
|
||||||
animation: panelIn 0.32s var(--ease-out) both;
|
animation: panelIn 0.32s var(--ease-out) both;
|
||||||
}
|
}
|
||||||
.animate-check-pop {
|
.animate-shimmer {
|
||||||
animation: check-pop 0.25s var(--ease-spring) forwards;
|
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade-in,
|
.animate-fade-in,
|
||||||
.animate-message-in,
|
.animate-message-in,
|
||||||
.animate-panel-in,
|
.animate-panel-in,
|
||||||
.animate-check-pop {
|
.animate-shimmer {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"**",
|
"**",
|
||||||
"!public",
|
"!public",
|
||||||
"!packages/mcp-server/src/preview",
|
"!packages/mcp-server/src/preview",
|
||||||
|
"!packages/mcp-server/shell/index.html",
|
||||||
"!lib/model-catalog.json"
|
"!lib/model-catalog.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Cloud } from "lucide-react"
|
|
||||||
import type { ComponentProps, ElementRef, ReactNode } from "react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
CommandSeparator,
|
|
||||||
CommandShortcut,
|
|
||||||
} from "@/components/ui/command"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>
|
|
||||||
|
|
||||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
|
||||||
<Dialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
|
|
||||||
|
|
||||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
|
||||||
<DialogTrigger {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
|
||||||
title?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorContent = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
title = "Model Selector",
|
|
||||||
...props
|
|
||||||
}: ModelSelectorContentProps) => (
|
|
||||||
<DialogContent className={cn("p-0", className)} {...props}>
|
|
||||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
||||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
|
|
||||||
|
|
||||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
|
||||||
<CommandDialog {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
|
|
||||||
|
|
||||||
export const ModelSelectorInput = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorInputProps) => (
|
|
||||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
|
|
||||||
|
|
||||||
export const ModelSelectorList = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorListProps) => {
|
|
||||||
const listRef = useRef<ElementRef<typeof CommandList>>(null)
|
|
||||||
const [showShadow, setShowShadow] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listElement = listRef.current
|
|
||||||
if (!listElement) return
|
|
||||||
|
|
||||||
const checkScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listElement
|
|
||||||
// Show shadow if there is more content below
|
|
||||||
// Using a small threshold to handle fractional pixel rendering
|
|
||||||
setShowShadow(
|
|
||||||
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll()
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
listElement.addEventListener("scroll", checkScroll)
|
|
||||||
window.addEventListener("resize", checkScroll)
|
|
||||||
|
|
||||||
// Observe content changes (e.g. async loading of items)
|
|
||||||
const observer = new MutationObserver(checkScroll)
|
|
||||||
observer.observe(listElement, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
listElement.removeEventListener("scroll", checkScroll)
|
|
||||||
window.removeEventListener("resize", checkScroll)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<CommandList
|
|
||||||
ref={listRef}
|
|
||||||
className={cn(
|
|
||||||
// Hide scrollbar on all platforms
|
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
{/* Bottom shadow indicator for scrollable content */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
|
||||||
showShadow ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
|
|
||||||
|
|
||||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
|
||||||
<CommandEmpty {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
|
|
||||||
|
|
||||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
|
||||||
<CommandGroup {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
|
|
||||||
|
|
||||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
|
||||||
<CommandItem {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
|
|
||||||
|
|
||||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
|
||||||
<CommandShortcut {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
|
||||||
typeof CommandSeparator
|
|
||||||
>
|
|
||||||
|
|
||||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
|
||||||
<CommandSeparator {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorLogoProps = Omit<
|
|
||||||
ComponentProps<"img">,
|
|
||||||
"src" | "alt"
|
|
||||||
> & {
|
|
||||||
provider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorLogo = ({
|
|
||||||
provider,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoProps) => {
|
|
||||||
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
|
|
||||||
if (provider === "amazon-bedrock") {
|
|
||||||
return <Cloud className={cn("size-4", className)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
|
||||||
{...props}
|
|
||||||
alt={`${provider} logo`}
|
|
||||||
className={cn("size-4 dark:invert", className)}
|
|
||||||
height={16}
|
|
||||||
src={`https://models.dev/logos/${provider}.svg`}
|
|
||||||
width={16}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
|
|
||||||
|
|
||||||
export const ModelSelectorLogoGroup = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorLogoGroupProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorNameProps = ComponentProps<"span">
|
|
||||||
|
|
||||||
export const ModelSelectorName = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ModelSelectorNameProps) => (
|
|
||||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ModelSelectorSectionHeaderProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelSelectorSectionHeader = ({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
className,
|
|
||||||
}: ModelSelectorSectionHeaderProps) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
@@ -1,186 +1,83 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
|
||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
import { BrainIcon, ChevronDownIcon } from "lucide-react"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Shimmer } from "./shimmer"
|
|
||||||
|
|
||||||
type ReasoningContextValue = {
|
export type ReasoningProps = {
|
||||||
isStreaming: boolean
|
className?: string
|
||||||
isOpen: boolean
|
|
||||||
setIsOpen: (open: boolean) => void
|
|
||||||
duration: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
|
|
||||||
|
|
||||||
export const useReasoning = () => {
|
|
||||||
const context = useContext(ReasoningContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("Reasoning components must be used within Reasoning")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
open?: boolean
|
/** Open while streaming, then folds once about a second after it ends */
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
|
||||||
duration?: number
|
children: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_CLOSE_DELAY = 1000
|
const AUTO_CLOSE_DELAY = 1000
|
||||||
const MS_IN_S = 1000
|
const MS_IN_S = 1000
|
||||||
|
|
||||||
export const Reasoning = memo(
|
export function Reasoning({
|
||||||
({
|
className,
|
||||||
className,
|
isStreaming = false,
|
||||||
isStreaming = false,
|
defaultOpen = true,
|
||||||
open,
|
getThinkingMessage,
|
||||||
defaultOpen = true,
|
children,
|
||||||
onOpenChange,
|
}: ReasoningProps) {
|
||||||
duration: durationProp,
|
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||||
children,
|
const [duration, setDuration] = useState<number>()
|
||||||
...props
|
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
||||||
}: ReasoningProps) => {
|
const [startTime, setStartTime] = useState<number | null>(null)
|
||||||
const [isOpen, setIsOpen] = useControllableState({
|
|
||||||
prop: open,
|
|
||||||
defaultProp: defaultOpen,
|
|
||||||
onChange: onOpenChange,
|
|
||||||
})
|
|
||||||
const [duration, setDuration] = useControllableState({
|
|
||||||
prop: durationProp,
|
|
||||||
defaultProp: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const [hasAutoClosed, setHasAutoClosed] = useState(false)
|
// Track duration when streaming starts and ends
|
||||||
const [startTime, setStartTime] = useState<number | null>(null)
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
// Track duration when streaming starts and ends
|
if (startTime === null) {
|
||||||
useEffect(() => {
|
setStartTime(Date.now())
|
||||||
if (isStreaming) {
|
|
||||||
if (startTime === null) {
|
|
||||||
setStartTime(Date.now())
|
|
||||||
}
|
|
||||||
} else if (startTime !== null) {
|
|
||||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
|
||||||
setStartTime(null)
|
|
||||||
}
|
}
|
||||||
}, [isStreaming, startTime, setDuration])
|
} else if (startTime !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
|
||||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
setStartTime(null)
|
||||||
useEffect(() => {
|
|
||||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
|
||||||
// Add a small delay before closing to allow user to see the content
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
setHasAutoClosed(true)
|
|
||||||
}, AUTO_CLOSE_DELAY)
|
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
setIsOpen(newOpen)
|
|
||||||
}
|
}
|
||||||
|
}, [isStreaming, startTime])
|
||||||
|
|
||||||
return (
|
// Auto-close once when streaming ends, after a short delay so the user
|
||||||
<ReasoningContext.Provider
|
// sees the content
|
||||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
useEffect(() => {
|
||||||
>
|
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||||
<Collapsible
|
const timer = setTimeout(() => {
|
||||||
className={cn("not-prose mb-4", className)}
|
setIsOpen(false)
|
||||||
onOpenChange={handleOpenChange}
|
setHasAutoClosed(true)
|
||||||
open={isOpen}
|
}, AUTO_CLOSE_DELAY)
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Collapsible>
|
|
||||||
</ReasoningContext.Provider>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ReasoningTriggerProps = ComponentProps<
|
return () => clearTimeout(timer)
|
||||||
typeof CollapsibleTrigger
|
}
|
||||||
> & {
|
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
|
||||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
return (
|
||||||
if (isStreaming || duration === 0) {
|
<Collapsible
|
||||||
return <Shimmer duration={1}>Thinking...</Shimmer>
|
className={cn("not-prose mb-4", className)}
|
||||||
}
|
onOpenChange={setIsOpen}
|
||||||
if (duration === undefined) {
|
open={isOpen}
|
||||||
return <p>Thought for a few seconds</p>
|
|
||||||
}
|
|
||||||
return <p>Thought for {duration} seconds</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReasoningTrigger = memo(
|
|
||||||
({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
getThinkingMessage = defaultGetThinkingMessage,
|
|
||||||
...props
|
|
||||||
}: ReasoningTriggerProps) => {
|
|
||||||
const { isStreaming, isOpen, duration } = useReasoning()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollapsibleTrigger
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<BrainIcon className="size-4" />
|
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-transform",
|
|
||||||
isOpen ? "rotate-180" : "rotate-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export type ReasoningContentProps = ComponentProps<
|
|
||||||
typeof CollapsibleContent
|
|
||||||
> & {
|
|
||||||
children: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReasoningContent = memo(
|
|
||||||
({ className, children, ...props }: ReasoningContentProps) => (
|
|
||||||
<CollapsibleContent
|
|
||||||
className={cn(
|
|
||||||
"mt-4 text-sm",
|
|
||||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<div className="whitespace-pre-wrap">{children}</div>
|
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||||
</CollapsibleContent>
|
<BrainIcon className="size-4" />
|
||||||
),
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
)
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
Reasoning.displayName = "Reasoning"
|
"size-4 transition-transform",
|
||||||
ReasoningTrigger.displayName = "ReasoningTrigger"
|
isOpen ? "rotate-180" : "rotate-0",
|
||||||
ReasoningContent.displayName = "ReasoningContent"
|
)}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
|
||||||
|
<div className="whitespace-pre-wrap">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { motion } from "motion/react"
|
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
|
||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type ElementType,
|
|
||||||
type JSX,
|
|
||||||
memo,
|
|
||||||
useMemo,
|
|
||||||
} from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export type TextShimmerProps = {
|
export type TextShimmerProps = {
|
||||||
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
|
|||||||
duration = 2,
|
duration = 2,
|
||||||
spread = 2,
|
spread = 2,
|
||||||
}: TextShimmerProps) => {
|
}: TextShimmerProps) => {
|
||||||
const MotionComponent = motion.create(
|
|
||||||
Component as keyof JSX.IntrinsicElements,
|
|
||||||
)
|
|
||||||
|
|
||||||
const dynamicSpread = useMemo(
|
const dynamicSpread = useMemo(
|
||||||
() => (children?.length ?? 0) * spread,
|
() => (children?.length ?? 0) * spread,
|
||||||
[children, spread],
|
[children, spread],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionComponent
|
<Component
|
||||||
animate={{ backgroundPosition: "0% center" }}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
initial={{ backgroundPosition: "100% center" }}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--spread": `${dynamicSpread}px`,
|
"--spread": `${dynamicSpread}px`,
|
||||||
|
"--shimmer-duration": `${duration}s`,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
transition={{
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
duration,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MotionComponent>
|
</Component>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
|
||||||
import type React from "react"
|
|
||||||
import { Button, type buttonVariants } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
interface ButtonWithTooltipProps
|
|
||||||
extends React.ComponentProps<"button">,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
tooltipContent: string
|
|
||||||
children: React.ReactNode
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ButtonWithTooltip({
|
|
||||||
tooltipContent,
|
|
||||||
children,
|
|
||||||
...buttonProps
|
|
||||||
}: ButtonWithTooltipProps) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button {...buttonProps}>{children}</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs text-wrap">
|
|
||||||
{tooltipContent}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useParams } from "next/navigation"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
import { useLocale } from "@/components/canvas/locale-context"
|
||||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
import {
|
||||||
|
DRAWIO_CSS,
|
||||||
|
type DrawioSource,
|
||||||
|
getDrawioSrc,
|
||||||
|
webDrawioSource,
|
||||||
|
} from "@/lib/drawio/drawio-config"
|
||||||
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { STORAGE_KEYS } from "@/lib/storage"
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
@@ -13,28 +16,42 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useCanvasStore } from "@/stores/canvas-store"
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
import { useSettingsStore } from "@/stores/settings-store"
|
import { useSettingsStore } from "@/stores/settings-store"
|
||||||
|
|
||||||
|
/** Size of the canvas area, for overlays that stay inside it */
|
||||||
|
export interface CanvasSize {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
||||||
* the "ask AI" button laid over it (only when the page can drive draw.io
|
* an overlay laid over it (the web app's "ask AI" button; only when the
|
||||||
* directly, not with an external cross-origin draw.io).
|
* page can drive draw.io directly, not with an external cross-origin
|
||||||
|
* draw.io).
|
||||||
*/
|
*/
|
||||||
export function CanvasStage({
|
export function CanvasStage({
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
hideOverlays = false,
|
hideOverlays = false,
|
||||||
|
overlay,
|
||||||
|
drawioSource,
|
||||||
|
waitForSavedLocale = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
|
/** Hide the overlay (e.g. while the start screen covers the canvas) */
|
||||||
hideOverlays?: boolean
|
hideOverlays?: boolean
|
||||||
|
/** Rendered over the canvas once draw.io is ready */
|
||||||
|
overlay?: (size: CanvasSize) => React.ReactNode
|
||||||
|
/** Where the editor comes from; none: the web app's own copy (the MCP
|
||||||
|
* shell passes what its server configured) */
|
||||||
|
drawioSource?: DrawioSource
|
||||||
|
/** Web app: the page is about to switch to the language picked last
|
||||||
|
* time (useSavedLocale in the workspace); do not load draw.io in the
|
||||||
|
* URL's language meanwhile */
|
||||||
|
waitForSavedLocale?: boolean
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const params = useParams<{ lang: string }>()
|
const lang = useLocale()
|
||||||
const lang = (
|
|
||||||
i18n.locales.includes(params.lang as Locale)
|
|
||||||
? params.lang
|
|
||||||
: i18n.defaultLocale
|
|
||||||
) as Locale
|
|
||||||
const {
|
const {
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
@@ -43,14 +60,8 @@ export function CanvasStage({
|
|||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
const engine = useChatEngine()
|
|
||||||
const isDark = useSettingsStore((s) => s.isDark)
|
const isDark = useSettingsStore((s) => s.isDark)
|
||||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
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 when the chat panel starts sliding in or out
|
// Canvas width when the chat panel starts sliding in or out
|
||||||
const slideStartWidthRef = useRef(0)
|
const slideStartWidthRef = useRef(0)
|
||||||
|
|
||||||
@@ -58,20 +69,24 @@ export function CanvasStage({
|
|||||||
// null until draw.io tells us whether we can drive it directly
|
// null until draw.io tells us whether we can drive it directly
|
||||||
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The page is about to switch to the language picked last time
|
// Load draw.io once, in the language the page will have
|
||||||
// (useSavedLocale): load draw.io once, in that language
|
if (waitForSavedLocale) {
|
||||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||||
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
if (
|
||||||
return
|
saved &&
|
||||||
|
saved !== lang &&
|
||||||
|
i18n.locales.includes(saved as Locale)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
|
||||||
// The theme class is set before the page renders; the settings store
|
// The theme class is set before the page renders; the settings store
|
||||||
// may not have read it yet
|
// may not have read it yet
|
||||||
const dark = document.documentElement.classList.contains("dark")
|
const dark = document.documentElement.classList.contains("dark")
|
||||||
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
|
||||||
}, [lang])
|
}, [lang])
|
||||||
|
|
||||||
// A new iframe starts over: draw.io reports load and access again
|
// A new iframe starts over: draw.io reports load and access again
|
||||||
@@ -87,8 +102,7 @@ export function CanvasStage({
|
|||||||
if (editorAccess !== false || !src) return
|
if (editorAccess !== false || !src) return
|
||||||
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
||||||
if (startedDark === isDark) return
|
if (startedDark === isDark) return
|
||||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
|
||||||
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
|
||||||
}, [isDark, editorAccess, lang, src])
|
}, [isDark, editorAccess, lang, src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,8 +155,7 @@ export function CanvasStage({
|
|||||||
key={src}
|
key={src}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
src={src}
|
src={src}
|
||||||
configCss={DRAWIO_CSS}
|
css={DRAWIO_CSS}
|
||||||
editorCss={DRAWIO_CSS}
|
|
||||||
dark={isDark}
|
dark={isDark}
|
||||||
onLoad={onDrawioLoad}
|
onLoad={onDrawioLoad}
|
||||||
onAutoSave={handleDiagramAutoSave}
|
onAutoSave={handleDiagramAutoSave}
|
||||||
@@ -164,13 +177,7 @@ export function CanvasStage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showOverlays && (
|
{showOverlays && overlay?.(size)}
|
||||||
<SelectionAsk
|
|
||||||
width={size.width}
|
|
||||||
height={size.height}
|
|
||||||
hidden={engine.isBusy || !onFirstPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { VersionThumb } from "@/components/canvas/version-card"
|
||||||
|
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -11,11 +13,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
|||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { describeChanges } from "@/lib/version-text"
|
import { describeChangeSummary } from "@/lib/version-text"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
import { useVersionsStore } from "@/stores/versions-store"
|
|
||||||
import { useChatEngine } from "./chat-engine"
|
|
||||||
import { VersionThumb } from "./tool-activity"
|
|
||||||
|
|
||||||
function Sheet({
|
function Sheet({
|
||||||
label,
|
label,
|
||||||
@@ -40,12 +39,11 @@ function Sheet({
|
|||||||
export function CompareDialog() {
|
export function CompareDialog() {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const t = dict.versions
|
const t = dict.versions
|
||||||
const engine = useChatEngine()
|
const { versions, isBusy, restore } = useVersionsContext()
|
||||||
const { getVersionSvg, chartXML } = useDiagram()
|
const { getVersionSvg, chartXML } = useDiagram()
|
||||||
const versionId = useUiStore((s) => s.compareVersionId)
|
const versionId = useUiStore((s) => s.compareVersionId)
|
||||||
const openCompare = useUiStore((s) => s.openCompare)
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
|
||||||
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
||||||
|
|
||||||
const index = versions.findIndex((v) => v.id === versionId)
|
const index = versions.findIndex((v) => v.id === versionId)
|
||||||
@@ -56,11 +54,11 @@ export function CompareDialog() {
|
|||||||
() => !version || isSameDocument(version.xml, chartXML),
|
() => !version || isSameDocument(version.xml, chartXML),
|
||||||
[version, chartXML],
|
[version, chartXML],
|
||||||
)
|
)
|
||||||
// What the canvas has changed since this version on the first page
|
// What the canvas has changed since this version on the page it was
|
||||||
// (null: nothing there)
|
// made on (null: nothing there)
|
||||||
const changes = useMemo(() => {
|
const changes = useMemo(() => {
|
||||||
if (!version || same) return null
|
if (!version || same) return null
|
||||||
const { summary } = diffDiagrams(version.xml, chartXML)
|
const { summary } = diffDiagrams(version.xml, chartXML, version.pageId)
|
||||||
return Object.values(summary).some((n) => n > 0) ? summary : null
|
return Object.values(summary).some((n) => n > 0) ? summary : null
|
||||||
}, [version, chartXML, same])
|
}, [version, chartXML, same])
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ export function CompareDialog() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [versionId, engine.isBusy])
|
}, [versionId, isBusy])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -98,7 +96,7 @@ export function CompareDialog() {
|
|||||||
: same
|
: same
|
||||||
? t.isOnCanvas
|
? t.isOnCanvas
|
||||||
: changes
|
: changes
|
||||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
|
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
|
||||||
: formatMessage(t.differsElsewhere, {
|
: formatMessage(t.differsElsewhere, {
|
||||||
n: version.number,
|
n: version.number,
|
||||||
})}
|
})}
|
||||||
@@ -139,10 +137,10 @@ export function CompareDialog() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!version || same || engine.isBusy}
|
disabled={!version || same || isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!version) return
|
if (!version) return
|
||||||
engine.restoreVersion(version.id)
|
restore(version.id)
|
||||||
closeCompare()
|
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"
|
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"
|
||||||
@@ -30,10 +30,11 @@ export interface DrawioFrameHandle {
|
|||||||
|
|
||||||
interface DrawioFrameProps {
|
interface DrawioFrameProps {
|
||||||
src: string
|
src: string
|
||||||
/** CSS sent with the configure message (works cross-origin too) */
|
/**
|
||||||
configCss: string
|
* Theme CSS: sent with the configure message (works cross-origin too)
|
||||||
/** CSS injected once the editor is reachable directly (same origin) */
|
* and, same origin, injected as a <style> so it updates without a reload
|
||||||
editorCss: string
|
*/
|
||||||
|
css: string
|
||||||
/** draw.io's dark mode, switched in place when the editor is reachable */
|
/** draw.io's dark mode, switched in place when the editor is reachable */
|
||||||
dark: boolean
|
dark: boolean
|
||||||
onLoad?: () => void
|
onLoad?: () => void
|
||||||
@@ -52,25 +53,14 @@ const STYLE_ID = "next-ai-drawio-theme"
|
|||||||
*/
|
*/
|
||||||
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
||||||
function DrawioFrame(
|
function DrawioFrame(
|
||||||
{
|
{ src, css, dark, onLoad, onAutoSave, onExport, onEditorAccess },
|
||||||
src,
|
|
||||||
configCss,
|
|
||||||
editorCss,
|
|
||||||
dark,
|
|
||||||
onLoad,
|
|
||||||
onAutoSave,
|
|
||||||
onExport,
|
|
||||||
onEditorAccess,
|
|
||||||
},
|
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const editorUiRef = useRef<any>(null)
|
const editorUiRef = useRef<any>(null)
|
||||||
const attachedRef = useRef(false)
|
const attachedRef = useRef(false)
|
||||||
const configCssRef = useRef(configCss)
|
const cssRef = useRef(css)
|
||||||
configCssRef.current = configCss
|
cssRef.current = css
|
||||||
const editorCssRef = useRef(editorCss)
|
|
||||||
editorCssRef.current = editorCss
|
|
||||||
const darkRef = useRef(dark)
|
const darkRef = useRef(dark)
|
||||||
darkRef.current = dark
|
darkRef.current = dark
|
||||||
// Handlers change on every render; the message listener reads refs
|
// Handlers change on every render; the message listener reads refs
|
||||||
@@ -111,7 +101,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
style.id = STYLE_ID
|
style.id = STYLE_ID
|
||||||
doc.head.appendChild(style)
|
doc.head.appendChild(style)
|
||||||
}
|
}
|
||||||
style.textContent = editorCssRef.current
|
style.textContent = cssRef.current
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin: the configure message already applied it
|
// Cross-origin: the configure message already applied it
|
||||||
}
|
}
|
||||||
@@ -119,7 +109,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeCss()
|
applyThemeCss()
|
||||||
}, [editorCss])
|
}, [css])
|
||||||
|
|
||||||
// Same-origin only: draw.io's own dark mode, without a reload
|
// Same-origin only: draw.io's own dark mode, without a reload
|
||||||
const applyDarkMode = () => {
|
const applyDarkMode = () => {
|
||||||
@@ -209,7 +199,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
|
|||||||
send("configure", {
|
send("configure", {
|
||||||
config: {
|
config: {
|
||||||
...DRAWIO_CONFIG,
|
...DRAWIO_CONFIG,
|
||||||
css: configCssRef.current,
|
css: cssRef.current,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react"
|
||||||
|
import type { Locale } from "@/lib/i18n/config"
|
||||||
|
|
||||||
|
const LocaleContext = createContext<Locale | null>(null)
|
||||||
|
|
||||||
|
/** The page's language; the web layout feeds it from its route param */
|
||||||
|
export function LocaleProvider({
|
||||||
|
locale,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
locale: Locale
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={locale}>
|
||||||
|
{children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocale(): Locale {
|
||||||
|
const locale = useContext(LocaleContext)
|
||||||
|
if (!locale) {
|
||||||
|
throw new Error("useLocale must be used within a LocaleProvider")
|
||||||
|
}
|
||||||
|
return locale
|
||||||
|
}
|
||||||
@@ -10,25 +10,25 @@ const GAP = 10
|
|||||||
/**
|
/**
|
||||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
* "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.
|
* focus to the chat input, where the selection already shows as a chip.
|
||||||
|
* Hidden while an answer is changing the canvas.
|
||||||
*/
|
*/
|
||||||
export function SelectionAsk({
|
export function SelectionAsk({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
hidden,
|
|
||||||
}: {
|
}: {
|
||||||
/** Size of the canvas area, for keeping the button inside it */
|
/** Size of the canvas area, for keeping the button inside it */
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
hidden: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const rect = useCanvasStore((s) => s.selectionRect)
|
const rect = useCanvasStore((s) => s.selectionRect)
|
||||||
const selection = useCanvasStore((s) => s.selection)
|
const selection = useCanvasStore((s) => s.selection)
|
||||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||||
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||||
|
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||||
|
|
||||||
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Selection scrolled out of view
|
// Selection scrolled out of view
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
|
import {
|
||||||
|
useVersionsContext,
|
||||||
|
type VersionItem,
|
||||||
|
} from "@/components/canvas/versions-context"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||||
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
|
|
||||||
|
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||||
|
export function VersionThumb({
|
||||||
|
svg,
|
||||||
|
dimmed,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
/** undefined or null: still being made; "": none could be made */
|
||||||
|
svg: string | null | undefined
|
||||||
|
dimmed?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const t = useDictionary().versions
|
||||||
|
if (svg) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||||
|
<img
|
||||||
|
src={svg}
|
||||||
|
alt=""
|
||||||
|
className={cn(
|
||||||
|
"object-contain transition-opacity",
|
||||||
|
dimmed && "opacity-40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (svg === "") {
|
||||||
|
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Shimmer as="span" className="text-xs">
|
||||||
|
{t.rendering}
|
||||||
|
</Shimmer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionCardProps extends React.ComponentProps<"div"> {
|
||||||
|
version: VersionItem
|
||||||
|
/** The newest version shows its picture; older ones fold into one row */
|
||||||
|
isLatest: boolean
|
||||||
|
/** What happened ("Drew the diagram"); an undone change says so instead */
|
||||||
|
label: string
|
||||||
|
/** Goes after the summary text (the chat adds its "Show XML" link) */
|
||||||
|
afterSummary?: React.ReactNode
|
||||||
|
/** Panel below the card (the chat shows the AI's XML there) */
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for one diagram version: picture, number, label, what changed, and
|
||||||
|
* the action (undo or redo on the latest, restore on older ones). The
|
||||||
|
* picture opens Compare. Other attributes go on the card's element.
|
||||||
|
*/
|
||||||
|
export function VersionCard({
|
||||||
|
version,
|
||||||
|
isLatest,
|
||||||
|
label,
|
||||||
|
afterSummary,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: VersionCardProps) {
|
||||||
|
const t = useDictionary().versions
|
||||||
|
const source = useVersionsContext()
|
||||||
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
|
|
||||||
|
const isUndone = source.undoneId === version.id
|
||||||
|
const isOnCanvas = source.onCanvasId === version.id
|
||||||
|
const title = isUndone ? t.undone : label
|
||||||
|
const summary = version.fromScratch
|
||||||
|
? describeTotals(version.summary, t)
|
||||||
|
: describeChangeSummary(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{version.number}
|
||||||
|
</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={source.isBusy || isOnCanvas}
|
||||||
|
onClick={() => source.restore(version.id)}
|
||||||
|
className={actionClass}
|
||||||
|
data-testid="version-restore"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
const summaryLine = (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{summary}
|
||||||
|
{afterSummary}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isLatest) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border bg-card",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-testid="version-card"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<VersionThumb
|
||||||
|
svg={version.svg}
|
||||||
|
className="max-h-8 max-w-[52px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{badge}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{summaryLine}
|
||||||
|
</div>
|
||||||
|
{restoreButton}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo and redo belong 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 = source.canUndo || source.canRedo
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border border-border bg-card",
|
||||||
|
isOnCanvas && "border-foreground/15",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-testid="version-card"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
svg={version.svg}
|
||||||
|
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>
|
||||||
|
{/* Title row with the action; the summary runs under both */}
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||||
|
{badge}
|
||||||
|
<div className="truncate text-[13px] font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{canUndo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={source.isBusy}
|
||||||
|
onClick={() =>
|
||||||
|
source.canRedo ? source.redo() : source.undo()
|
||||||
|
}
|
||||||
|
className={actionClass}
|
||||||
|
data-testid="version-undo"
|
||||||
|
>
|
||||||
|
{source.canRedo ? (
|
||||||
|
<Redo2 className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Undo2 className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{source.canRedo ? t.redo : t.undo}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
restoreButton
|
||||||
|
)}
|
||||||
|
<div className="col-span-2 col-start-2 mt-0.5">
|
||||||
|
{summaryLine}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
|
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
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 */
|
/** Row of version thumbnails under the panel header; click to compare */
|
||||||
export function VersionStrip() {
|
export function VersionStrip({
|
||||||
|
minVersions = 2,
|
||||||
|
}: {
|
||||||
|
/** Hidden below this many versions: one version alone adds nothing to
|
||||||
|
* its card, unless the caller knows there is no card for it */
|
||||||
|
minVersions?: number
|
||||||
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
const { versions, onCanvasId } = useVersionsContext()
|
||||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
|
||||||
const openCompare = useUiStore((s) => s.openCompare)
|
const openCompare = useUiStore((s) => s.openCompare)
|
||||||
const engine = useChatEngine()
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Keep the newest version in view
|
// Keep the newest version in view
|
||||||
@@ -20,24 +23,7 @@ export function VersionStrip() {
|
|||||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||||
}, [versions.length])
|
}, [versions.length])
|
||||||
|
|
||||||
// Versions with no card in the chat: saved by older app versions, from
|
if (versions.length < minVersions) return null
|
||||||
// a turn that was retried, or drawn by a call that then failed its
|
|
||||||
// screenshot check (a call still running gets its card when done)
|
|
||||||
const hasCardless = versions.some(
|
|
||||||
(v) =>
|
|
||||||
!v.toolCallId ||
|
|
||||||
!engine.messages.some((m) =>
|
|
||||||
m.parts?.some((p) => {
|
|
||||||
const part = p as { toolCallId?: string; state?: string }
|
|
||||||
return (
|
|
||||||
part.toolCallId === v.toolCallId &&
|
|
||||||
part.state !== "output-error"
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// One version with a card: the strip adds nothing to it
|
|
||||||
if (versions.length < 2 && !hasCardless) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||||
@@ -49,13 +35,13 @@ export function VersionStrip() {
|
|||||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||||
>
|
>
|
||||||
{versions.map((version) => {
|
{versions.map((version) => {
|
||||||
const isCurrent = version.id === onCanvasVersionId
|
const isCurrent = version.id === onCanvasId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={version.id}
|
key={version.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCompare(version.id)}
|
onClick={() => openCompare(version.id)}
|
||||||
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||||
className={cn(
|
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",
|
"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
|
// An outline with a gap reads on the white
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react"
|
||||||
|
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||||
|
|
||||||
|
/** One saved state of the diagram, as the version card and the compare
|
||||||
|
* dialog show it */
|
||||||
|
export interface VersionItem {
|
||||||
|
id: string
|
||||||
|
/** Shown as v{number} */
|
||||||
|
number: number
|
||||||
|
/** Full multi-page document */
|
||||||
|
xml: string
|
||||||
|
/** The page the change was made on; none: the first page */
|
||||||
|
pageId?: string
|
||||||
|
/** Document before the change, when it is known */
|
||||||
|
beforeXml?: string
|
||||||
|
/** SVG data URL thumbnail; undefined: still being made; "": none could
|
||||||
|
* be made */
|
||||||
|
svg?: string
|
||||||
|
summary: ChangeSummary
|
||||||
|
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||||
|
fromScratch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the versions come from and what can be done with them. The web app
|
||||||
|
* fills it from the chat's version store; the MCP shell from the server's
|
||||||
|
* history.
|
||||||
|
*/
|
||||||
|
export interface VersionsSource {
|
||||||
|
versions: VersionItem[]
|
||||||
|
/** The version the canvas shows now, or null (hand edits, undone) */
|
||||||
|
onCanvasId: string | null
|
||||||
|
/** The latest version, when its change was undone */
|
||||||
|
undoneId: string | null
|
||||||
|
/** Something is changing the canvas: version actions wait */
|
||||||
|
isBusy: boolean
|
||||||
|
/** The latest version's change can be undone */
|
||||||
|
canUndo: boolean
|
||||||
|
/** The latest version's change was undone and can be put back */
|
||||||
|
canRedo: boolean
|
||||||
|
restore: (id: string) => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionsContext = createContext<VersionsSource | null>(null)
|
||||||
|
|
||||||
|
export function VersionsProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: VersionsSource
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<VersionsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</VersionsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVersionsContext(): VersionsSource {
|
||||||
|
const source = useContext(VersionsContext)
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(
|
||||||
|
"useVersionsContext must be used within a VersionsProvider",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Bookmark, Plus } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import {
|
|
||||||
createTemplate,
|
|
||||||
type TemplateCreateInput,
|
|
||||||
} from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface TemplateCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess: () => void
|
|
||||||
initialPrompt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
initialPrompt = "",
|
|
||||||
}: TemplateCreateDialogProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [title, setTitle] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [prompt, setPrompt] = useState("")
|
|
||||||
const [pinned, setPinned] = useState(false)
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Reset form when dialog opens with the latest initialPrompt
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setTitle("")
|
|
||||||
setDescription("")
|
|
||||||
setPrompt(initialPrompt)
|
|
||||||
setPinned(false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [open, initialPrompt])
|
|
||||||
|
|
||||||
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) {
|
|
||||||
setError(dict.templates.promptRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input: TemplateCreateInput = {
|
|
||||||
prompt: trimmedPrompt,
|
|
||||||
title: title.trim() || undefined,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
pinned,
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await createTemplate(input)
|
|
||||||
if (template) {
|
|
||||||
onSuccess()
|
|
||||||
onOpenChange(false)
|
|
||||||
} else {
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create template:", err)
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{dict.templates.createTitle}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dict.templates.createDescription}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Prompt field - required */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="prompt" className="text-foreground">
|
|
||||||
{dict.templates.promptLabel}
|
|
||||||
<span className="text-destructive ml-1">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="prompt"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder={dict.templates.promptPlaceholder}
|
|
||||||
className="min-h-[100px] resize-none break-words"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title" className="text-foreground">
|
|
||||||
{dict.templates.titleLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={dict.templates.titlePlaceholder}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.titleHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="description"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
{dict.templates.descriptionLabel}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
dict.templates.descriptionPlaceholder
|
|
||||||
}
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pinned switch */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="pinned"
|
|
||||||
className="flex items-center gap-2 text-foreground"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-4 h-4" />
|
|
||||||
{dict.templates.pinnedLabel}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.pinnedHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="pinned"
|
|
||||||
checked={pinned}
|
|
||||||
onCheckedChange={setPinned}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{dict.common.cancel}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting
|
|
||||||
? dict.common.loading
|
|
||||||
: dict.templates.createButton}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Bookmark, Edit2 } from "lucide-react"
|
import { Bookmark, Edit2, Plus } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useId, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { type Template, updateTemplate } from "@/lib/template-storage"
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type Template,
|
||||||
|
updateTemplate,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
interface TemplateEditDialogProps {
|
interface TemplateFormDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
template: Template | null
|
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
|
/** The template to edit; without one the dialog creates a new template */
|
||||||
|
template?: Template | null
|
||||||
|
initialPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateEditDialog({
|
// One form for creating and editing a template
|
||||||
|
export function TemplateFormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
template,
|
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: TemplateEditDialogProps) {
|
template,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateFormDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const id = useId()
|
||||||
|
const isEdit = !!template
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [prompt, setPrompt] = useState("")
|
const [prompt, setPrompt] = useState("")
|
||||||
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
// Fill the form each time the dialog opens, dropping cancelled edits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && template) {
|
if (!open) return
|
||||||
setTitle(template.title || "")
|
setTitle(template?.title || "")
|
||||||
setDescription(template.description || "")
|
setDescription(template?.description || "")
|
||||||
setPrompt(template.prompt || "")
|
setPrompt(template ? template.prompt || "" : initialPrompt)
|
||||||
setPinned(template.pinned || false)
|
setPinned(template?.pinned || false)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}, [open, template, initialPrompt])
|
||||||
}, [open, template])
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
if (!newOpen) {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
onOpenChange(newOpen)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// React submit events bubble through the portal; keep them away from
|
// React submit events bubble through the portal; keep them away from
|
||||||
// any form this dialog may be rendered in
|
// the chat form this dialog may be rendered in
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
if (!template) return
|
|
||||||
|
|
||||||
const trimmedPrompt = prompt.trim()
|
const trimmedPrompt = prompt.trim()
|
||||||
if (!trimmedPrompt) {
|
if (!trimmedPrompt) {
|
||||||
setError(dict.templates.promptRequired)
|
setError(dict.templates.promptRequired)
|
||||||
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
|
|||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
const failed = isEdit
|
||||||
|
? dict.templates.updateFailed
|
||||||
|
: dict.templates.createFailed
|
||||||
try {
|
try {
|
||||||
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
const fields = {
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
title: title.trim() || template.title,
|
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
pinned,
|
pinned,
|
||||||
}
|
}
|
||||||
|
// An emptied title keeps the old one when editing; a new template
|
||||||
const updated = await updateTemplate(template.id, updates)
|
// gets a default title from its prompt
|
||||||
if (updated) {
|
const result = template
|
||||||
|
? await updateTemplate(template.id, {
|
||||||
|
...fields,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
})
|
||||||
|
: await createTemplate({
|
||||||
|
...fields,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
})
|
||||||
|
if (result) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
} else {
|
} else {
|
||||||
setError(dict.templates.updateFailed)
|
setError(failed)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update template:", err)
|
console.error("Failed to save template:", err)
|
||||||
setError(dict.templates.updateFailed)
|
setError(failed)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Edit2 className="w-5 h-5" />
|
{isEdit ? (
|
||||||
{dict.templates.editTitle}
|
<Edit2 className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
{isEdit
|
||||||
|
? dict.templates.editTitle
|
||||||
|
: dict.templates.createTitle}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{dict.templates.editDescription}
|
{isEdit
|
||||||
|
? dict.templates.editDescription
|
||||||
|
: dict.templates.createDescription}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
|
|||||||
{/* Prompt field - required */}
|
{/* Prompt field - required */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-prompt"
|
htmlFor={`${id}-prompt`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.promptLabel}
|
{dict.templates.promptLabel}
|
||||||
<span className="text-destructive ml-1">*</span>
|
<span className="text-destructive ml-1">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-prompt"
|
id={`${id}-prompt`}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
placeholder={dict.templates.promptPlaceholder}
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
|
|||||||
{/* Title field - optional */}
|
{/* Title field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-title"
|
htmlFor={`${id}-title`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.titleLabel}
|
{dict.templates.titleLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-title"
|
id={`${id}-title`}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={dict.templates.titlePlaceholder}
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
|
|||||||
{/* Description field - optional */}
|
{/* Description field - optional */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-description"
|
htmlFor={`${id}-description`}
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
>
|
>
|
||||||
{dict.templates.descriptionLabel}
|
{dict.templates.descriptionLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="edit-description"
|
id={`${id}-description`}
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-pinned"
|
htmlFor={`${id}-pinned`}
|
||||||
className="flex items-center gap-2 text-foreground"
|
className="flex items-center gap-2 text-foreground"
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="edit-pinned"
|
id={`${id}-pinned`}
|
||||||
checked={pinned}
|
checked={pinned}
|
||||||
onCheckedChange={setPinned}
|
onCheckedChange={setPinned}
|
||||||
/>
|
/>
|
||||||
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
|
|||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? dict.common.loading
|
? dict.common.loading
|
||||||
: dict.common.save}
|
: isEdit
|
||||||
|
? dict.common.save
|
||||||
|
: dict.templates.createButton}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useParams } from "next/navigation"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -23,63 +24,33 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { formatRelativeTime } from "@/lib/relative-time"
|
||||||
import {
|
import {
|
||||||
deleteTemplate,
|
deleteTemplate,
|
||||||
duplicateTemplate,
|
duplicateTemplate,
|
||||||
exportTemplates,
|
exportTemplates,
|
||||||
getAllTemplates,
|
getAllTemplates,
|
||||||
importTemplates,
|
importTemplates,
|
||||||
incrementClickCount,
|
|
||||||
incrementRunCount,
|
incrementRunCount,
|
||||||
searchTemplates,
|
searchTemplates,
|
||||||
type Template,
|
type Template,
|
||||||
updateTemplate,
|
updateTemplate,
|
||||||
validateImportData,
|
validateImportData,
|
||||||
} from "@/lib/template-storage"
|
} from "@/lib/template-storage"
|
||||||
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
import { TemplateFormDialog } from "./TemplateFormDialog"
|
||||||
import { TemplateEditDialog } from "./TemplateEditDialog"
|
|
||||||
|
|
||||||
interface TemplatePanelProps {
|
interface TemplatePanelProps {
|
||||||
setInput: (input: string) => void
|
|
||||||
/** Whether it was sent */
|
/** Whether it was sent */
|
||||||
onSendTemplate?: (template: Template) => Promise<boolean>
|
onSendTemplate: (template: Template) => Promise<boolean>
|
||||||
currentInput?: string
|
currentInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
|
||||||
if (!timestamp) return neverUsedText
|
|
||||||
const now = Date.now()
|
|
||||||
const diffMs = now - timestamp
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
|
||||||
if (diffMins < 1) return rtf.format(0, "minute")
|
|
||||||
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
|
||||||
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
|
||||||
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
|
||||||
} catch {
|
|
||||||
// Fallback if Intl.RelativeTimeFormat is not available
|
|
||||||
if (diffMins < 1) return "<1m ago"
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplatePanel({
|
export function TemplatePanel({
|
||||||
setInput,
|
|
||||||
onSendTemplate,
|
onSendTemplate,
|
||||||
currentInput = "",
|
currentInput = "",
|
||||||
}: TemplatePanelProps) {
|
}: TemplatePanelProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const params = useParams<{ lang: string }>()
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
@@ -111,10 +82,6 @@ export function TemplatePanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
|
||||||
window.addEventListener("templatesChanged", loadTemplates)
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("templatesChanged", loadTemplates)
|
|
||||||
}, [loadTemplates])
|
}, [loadTemplates])
|
||||||
|
|
||||||
const handleCreateSuccess = () => {
|
const handleCreateSuccess = () => {
|
||||||
@@ -133,7 +100,7 @@ export function TemplatePanel({
|
|||||||
const handleDuplicate = async (template: Template) => {
|
const handleDuplicate = async (template: Template) => {
|
||||||
const duplicated = await duplicateTemplate(
|
const duplicated = await duplicateTemplate(
|
||||||
template.id,
|
template.id,
|
||||||
dict.templates.copySuffix || "(copy)",
|
dict.templates.copySuffix,
|
||||||
)
|
)
|
||||||
if (duplicated) {
|
if (duplicated) {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@@ -179,23 +146,13 @@ export function TemplatePanel({
|
|||||||
|
|
||||||
// Actually send the template
|
// Actually send the template
|
||||||
const sendTemplate = async (template: Template) => {
|
const sendTemplate = async (template: Template) => {
|
||||||
if (onSendTemplate) {
|
// A run only when it was sent (not while a message is still being
|
||||||
// Sent first, while the chat is the one it was clicked in (the
|
// sent, or attachments read)
|
||||||
// counts wait for storage). A run only when it was sent (not
|
if (await onSendTemplate(template)) {
|
||||||
// while a message is still being sent, or attachments read).
|
// Increment run count and update lastUsedAt
|
||||||
const sending = onSendTemplate(template)
|
await incrementRunCount(template.id)
|
||||||
// Increment click count only when actually sending
|
// Reload to show updated stats
|
||||||
await incrementClickCount(template.id)
|
loadTemplates()
|
||||||
if (await sending) {
|
|
||||||
// Increment run count and update lastUsedAt
|
|
||||||
await incrementRunCount(template.id)
|
|
||||||
// Reload to show updated stats
|
|
||||||
loadTemplates()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await incrementClickCount(template.id)
|
|
||||||
// Fallback: just fill the input if no send callback provided
|
|
||||||
setInput(template.prompt)
|
|
||||||
}
|
}
|
||||||
setConfirmSendDialogOpen(false)
|
setConfirmSendDialogOpen(false)
|
||||||
setTemplateToSend(null)
|
setTemplateToSend(null)
|
||||||
@@ -218,7 +175,7 @@ export function TemplatePanel({
|
|||||||
if (templates.length === 0) {
|
if (templates.length === 0) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: dict.templates.exportEmpty || "No templates to export",
|
text: dict.templates.exportEmpty,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,8 +217,7 @@ export function TemplatePanel({
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
setImportMessage({
|
setImportMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text:
|
text: dict.templates.importNoFile,
|
||||||
dict.templates.importNoFile || "Please select a JSON file",
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -370,7 +326,7 @@ export function TemplatePanel({
|
|||||||
<div className="mt-3">{importMessageBox}</div>
|
<div className="mt-3">{importMessageBox}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
@@ -500,7 +456,7 @@ export function TemplatePanel({
|
|||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
{template.runCount > 0
|
{template.runCount > 0
|
||||||
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
|
||||||
: dict.templates.neverUsed}
|
: dict.templates.neverUsed}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
@@ -517,10 +473,8 @@ export function TemplatePanel({
|
|||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
template.pinned
|
template.pinned
|
||||||
? dict.templates
|
? dict.templates.unpin
|
||||||
.unpin || "Unpin"
|
: dict.templates.pin
|
||||||
: dict.templates.pin ||
|
|
||||||
"Pin"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Bookmark
|
<Bookmark
|
||||||
@@ -545,10 +499,7 @@ export function TemplatePanel({
|
|||||||
handleDuplicate(template)
|
handleDuplicate(template)
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
title={
|
title={dict.templates.duplicate}
|
||||||
dict.templates.duplicate ||
|
|
||||||
"Duplicate"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -570,13 +521,13 @@ export function TemplatePanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateEditDialog
|
<TemplateFormDialog
|
||||||
open={editDialogOpen}
|
open={editDialogOpen}
|
||||||
onOpenChange={setEditDialogOpen}
|
onOpenChange={setEditDialogOpen}
|
||||||
template={templateToEdit}
|
template={templateToEdit}
|
||||||
@@ -591,12 +542,10 @@ export function TemplatePanel({
|
|||||||
<AlertDialogContent className="max-w-sm">
|
<AlertDialogContent className="max-w-sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{dict.templates.deleteTitle ||
|
{dict.templates.deleteTitle}
|
||||||
"Delete this template?"}
|
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{dict.templates.deleteDescription ||
|
{dict.templates.deleteDescription}
|
||||||
"This will permanently delete this template. This action cannot be undone."}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -621,12 +570,10 @@ export function TemplatePanel({
|
|||||||
<AlertDialogContent className="max-w-sm">
|
<AlertDialogContent className="max-w-sm">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
{dict.templates.confirmSendTitle ||
|
{dict.templates.confirmSendTitle}
|
||||||
"Replace current input?"}
|
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{dict.templates.confirmSendDescription ||
|
{dict.templates.confirmSendDescription}
|
||||||
"You have unsent content in the input. Sending this template will replace it."}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -634,8 +581,7 @@ export function TemplatePanel({
|
|||||||
{dict.common.cancel}
|
{dict.common.cancel}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleConfirmSend}>
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
{dict.templates.confirmSendButton ||
|
{dict.templates.confirmSendButton}
|
||||||
"Send Template"}
|
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
|||||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||||
|
|
||||||
export type ValidationStatus =
|
export type ValidationStatus =
|
||||||
| "idle"
|
|
||||||
| "capturing"
|
| "capturing"
|
||||||
| "validating"
|
| "validating"
|
||||||
| "success"
|
| "success"
|
||||||
@@ -159,13 +158,10 @@ export function ValidationCard({
|
|||||||
color: "text-gray-600 bg-gray-50",
|
color: "text-gray-600 bg-gray-50",
|
||||||
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
icon: <Check className="h-4 w-4" aria-hidden="true" />,
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusDisplay = getStatusDisplay()
|
const statusDisplay = getStatusDisplay()
|
||||||
if (!statusDisplay || state.status === "idle") return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||||
|
|||||||
+153
-211
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
|
|||||||
import {
|
import {
|
||||||
type ChatStatus,
|
type ChatStatus,
|
||||||
DefaultChatTransport,
|
DefaultChatTransport,
|
||||||
|
getToolName,
|
||||||
isToolUIPart,
|
isToolUIPart,
|
||||||
type UIMessage,
|
|
||||||
} from "ai"
|
} from "ai"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
@@ -19,14 +19,10 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { flushSync } from "react-dom"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import {
|
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||||
keepFileVars,
|
|
||||||
useDiagramToolHandlers,
|
|
||||||
} from "@/hooks/use-diagram-tool-handlers"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import {
|
import {
|
||||||
getSelectedAIConfig,
|
getSelectedAIConfig,
|
||||||
@@ -34,14 +30,20 @@ import {
|
|||||||
useModelConfig,
|
useModelConfig,
|
||||||
} from "@/hooks/use-model-config"
|
} from "@/hooks/use-model-config"
|
||||||
import {
|
import {
|
||||||
getSessionVersions,
|
|
||||||
type SessionData,
|
type SessionData,
|
||||||
useSessionManager,
|
useSessionManager,
|
||||||
} from "@/hooks/use-session-manager"
|
} from "@/hooks/use-session-manager"
|
||||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
|
import {
|
||||||
|
type ChatToolName,
|
||||||
|
type ChatUIMessage,
|
||||||
|
type MessageSelection,
|
||||||
|
noticeMessage,
|
||||||
|
} from "@/lib/chat-message"
|
||||||
import { buildChatHeaders } from "@/lib/chat-request"
|
import { buildChatHeaders } from "@/lib/chat-request"
|
||||||
|
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
|
||||||
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
@@ -79,7 +81,7 @@ const SAVE_DEBOUNCE_MS = 1000
|
|||||||
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
* Only checks the LAST tool part (most recent tool call), not all tool parts.
|
||||||
* A call the user stopped is not one to retry.
|
* A call the user stopped is not one to retry.
|
||||||
*/
|
*/
|
||||||
function hasToolErrors(messages: UIMessage[]): boolean {
|
function hasToolErrors(messages: ChatUIMessage[]): boolean {
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
if (lastMessage?.role !== "assistant") return false
|
if (lastMessage?.role !== "assistant") return false
|
||||||
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
|
||||||
@@ -90,56 +92,39 @@ function hasToolErrors(messages: UIMessage[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshots keep the full multi-page document, but the model only sees and
|
* Snapshots keep the full multi-page document, but the model sees and
|
||||||
* edits the first page, so give it the first page's mxGraphModel.
|
* edits one page, the one the user is viewing (the first when unknown), so
|
||||||
* Older snapshots already hold a single mxGraphModel and are returned as is.
|
* give it that page's mxGraphModel. Older snapshots already hold a single
|
||||||
|
* mxGraphModel and are returned as is.
|
||||||
*/
|
*/
|
||||||
function getFirstPageXml(xml: string): string {
|
function getPageXml(xml: string, pageId: string | null): string {
|
||||||
if (!xml.includes("<mxfile")) return xml
|
if (!xml.includes("<mxfile")) return xml
|
||||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
const model = pageModelXml(xml, pageId)
|
||||||
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
|
return model ? formatXML(model) : xml
|
||||||
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shapes sent with a user message (also kept in its metadata)
|
/** The page the user is viewing; null with an external draw.io */
|
||||||
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
|
function viewedPageId(): string | null {
|
||||||
|
return useCanvasStore.getState().currentPageId
|
||||||
|
}
|
||||||
|
|
||||||
function selectionOf(message: UIMessage): MessageSelection {
|
function selectionOf(message: ChatUIMessage): MessageSelection {
|
||||||
const meta = message.metadata as
|
const selected = message.metadata?.selectedCells
|
||||||
| { selectedCells?: MessageSelection }
|
return Array.isArray(selected) ? selected : []
|
||||||
| undefined
|
|
||||||
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newLangfuseSessionId() {
|
function newLangfuseSessionId() {
|
||||||
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const XML_PROLOG =
|
|
||||||
/^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>)*/i
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
|
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
|
||||||
* its pages inflated: previews and the model read it before draw.io saves
|
* its pages inflated: previews and the model read it before draw.io saves
|
||||||
* it again. A broken or cut off file gives null.
|
* it again. A broken or cut off file gives null.
|
||||||
*/
|
*/
|
||||||
async function readDiagramFile(file: File): Promise<string | null> {
|
async function readDiagramFile(file: File): Promise<string | null> {
|
||||||
const raw = await file.text()
|
const loaded = parseDrawioFileContent(await file.text())
|
||||||
// What XML allows before the root element: the declaration, comments,
|
return loaded.ok ? withUniquePageIds(loaded.xml) : null
|
||||||
// a DOCTYPE (the parser below expects the root first)
|
|
||||||
let text = raw.replace(XML_PROLOG, "")
|
|
||||||
// Editable SVG: the diagram is stored in the root's `content` attribute.
|
|
||||||
// Parsed whole: its DOCTYPE may define entities the content uses.
|
|
||||||
if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) {
|
|
||||||
const doc = new DOMParser().parseFromString(raw, "image/svg+xml")
|
|
||||||
text = (doc.documentElement?.getAttribute("content") ?? "").replace(
|
|
||||||
XML_PROLOG,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const loaded = parseDrawioFileContent(text)
|
|
||||||
if (!loaded.ok) return null
|
|
||||||
return withUniquePageIds(loaded.xml)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,15 +169,13 @@ function withUniquePageIds(xml: string): string {
|
|||||||
class FileReadError extends Error {}
|
class FileReadError extends Error {}
|
||||||
|
|
||||||
export interface ChatEngine {
|
export interface ChatEngine {
|
||||||
messages: UIMessage[]
|
messages: ChatUIMessage[]
|
||||||
status: ChatStatus
|
status: ChatStatus
|
||||||
error: Error | undefined
|
error: Error | undefined
|
||||||
isBusy: boolean
|
isBusy: boolean
|
||||||
/** The chat on screen is being left (saved, then another one shown) */
|
/** The chat on screen is being left (saved, then another one shown) */
|
||||||
isLeaving: boolean
|
isLeaving: boolean
|
||||||
/** A message is being sent again; the chat is not empty meanwhile */
|
setMessages: (messages: ChatUIMessage[]) => void
|
||||||
isResending: boolean
|
|
||||||
setMessages: (messages: UIMessage[]) => void
|
|
||||||
stop: () => void
|
stop: () => void
|
||||||
|
|
||||||
input: string
|
input: string
|
||||||
@@ -261,11 +244,10 @@ export function ChatEngineProvider({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
handleExport: onExport,
|
fetchDiagramXml,
|
||||||
exportResolversRef,
|
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef: liveChartXMLRef,
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvgRef,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
@@ -278,26 +260,10 @@ export function ChatEngineProvider({
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const urlSessionId = searchParams.get("session")
|
const urlSessionId = searchParams.get("session")
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
|
||||||
const openSettings = useUiStore((s) => s.openSettings)
|
const openSettings = useUiStore((s) => s.openSettings)
|
||||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||||
|
|
||||||
const onFetchChart = () => {
|
|
||||||
// Waits for the reply to its own export, by its tag
|
|
||||||
const tag = onExport()
|
|
||||||
return Promise.race([
|
|
||||||
new Promise<string>((resolve) => {
|
|
||||||
if (tag) exportResolversRef.current[tag] = resolve
|
|
||||||
}),
|
|
||||||
new Promise<string>((_, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
delete exportResolversRef.current[tag]
|
|
||||||
reject(new Error("Chart export timed out after 10 seconds"))
|
|
||||||
}, 10000)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
|
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
|
||||||
tooLong: dict.errors.fileTooLong,
|
tooLong: dict.errors.fileTooLong,
|
||||||
readFailed: dict.errors.fileReadFailed,
|
readFailed: dict.errors.fileReadFailed,
|
||||||
@@ -312,9 +278,6 @@ export function ChatEngineProvider({
|
|||||||
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
||||||
|
|
||||||
const [input, setInputState] = useState("")
|
const [input, setInputState] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
|
||||||
|
|
||||||
// Restore the unsent input from sessionStorage
|
// Restore the unsent input from sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -334,21 +297,7 @@ export function ChatEngineProvider({
|
|||||||
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(getApiEndpoint("/api/config"))
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
setDailyRequestLimit(data.dailyRequestLimit || 0)
|
|
||||||
setDailyTokenLimit(data.dailyTokenLimit || 0)
|
|
||||||
setTpmLimit(data.tpmLimit || 0)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const quotaManager = useQuotaManager({
|
const quotaManager = useQuotaManager({
|
||||||
dailyRequestLimit,
|
|
||||||
dailyTokenLimit,
|
|
||||||
tpmLimit,
|
|
||||||
onConfigModel: () => openSettings("models"),
|
onConfigModel: () => openSettings("models"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,6 +323,9 @@ export function ChatEngineProvider({
|
|||||||
|
|
||||||
// XML snapshot taken before each user message (keyed by message index)
|
// XML snapshot taken before each user message (keyed by message index)
|
||||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
|
||||||
|
// The page the running turn's model reads and writes: the one the user
|
||||||
|
// viewed when the message was sent (null: the first page)
|
||||||
|
const turnPageIdRef = useRef<string | null>(null)
|
||||||
// Index of the user message whose turn is running (versions belong to it)
|
// Index of the user message whose turn is running (versions belong to it)
|
||||||
const currentTurnRef = useRef(0)
|
const currentTurnRef = useRef(0)
|
||||||
// The chat (its generation) the running turn was sent in: a reply that
|
// The chat (its generation) the running turn was sent in: a reply that
|
||||||
@@ -383,20 +335,12 @@ export function ChatEngineProvider({
|
|||||||
const hasRestoredRef = useRef(false)
|
const hasRestoredRef = useRef(false)
|
||||||
const [isRestored, setIsRestored] = useState(false)
|
const [isRestored, setIsRestored] = useState(false)
|
||||||
|
|
||||||
// Latest chartXML for callbacks (avoids stale closures)
|
|
||||||
const chartXMLRef = useRef(chartXML)
|
|
||||||
// Session loaded without a diagram (prevents thumbnail contamination)
|
// Session loaded without a diagram (prevents thumbnail contamination)
|
||||||
const justLoadedSessionIdRef = useRef<string | null>(null)
|
const justLoadedSessionIdRef = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
|
||||||
if (chartXML) justLoadedSessionIdRef.current = null
|
if (chartXML) justLoadedSessionIdRef.current = null
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
const latestSvgRef = useRef(latestSvg)
|
|
||||||
useEffect(() => {
|
|
||||||
latestSvgRef.current = latestSvg
|
|
||||||
}, [latestSvg])
|
|
||||||
|
|
||||||
const autoRetryCountRef = useRef(0)
|
const autoRetryCountRef = useRef(0)
|
||||||
const continuationRetryCountRef = useRef(0)
|
const continuationRetryCountRef = useRef(0)
|
||||||
// Partial XML when output was truncated; non-empty means continuation mode
|
// Partial XML when output was truncated; non-empty means continuation mode
|
||||||
@@ -415,9 +359,6 @@ export function ChatEngineProvider({
|
|||||||
const [isLeaving, setIsLeaving] = useState(false)
|
const [isLeaving, setIsLeaving] = useState(false)
|
||||||
// The user's provider the current turn was sent to, for its errors
|
// The user's provider the current turn was sent to, for its errors
|
||||||
const requestProviderIdRef = useRef<string | undefined>(undefined)
|
const requestProviderIdRef = useRef<string | undefined>(undefined)
|
||||||
// A message is being sent again (retry, regenerate, edit), until the
|
|
||||||
// SDK has put it back
|
|
||||||
const [isResending, setIsResending] = useState(false)
|
|
||||||
// Presses of Stop: a check that began before one still knows of it after
|
// Presses of Stop: a check that began before one still knows of it after
|
||||||
// the next message clears stoppedRef
|
// the next message clears stoppedRef
|
||||||
const stopCountRef = useRef(0)
|
const stopCountRef = useRef(0)
|
||||||
@@ -449,12 +390,13 @@ export function ChatEngineProvider({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
validationRetryCountRef,
|
validationRetryCountRef,
|
||||||
// A preview undone just before the tool call is in this one already
|
// A preview undone just before the tool call is in this one already
|
||||||
chartXMLRef: liveChartXMLRef,
|
chartXMLRef,
|
||||||
|
turnPageIdRef,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onFetchChart,
|
onFetchChart: fetchDiagramXml,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
validateDiagram: validateWithFallback,
|
validateDiagram: validateWithFallback,
|
||||||
enableVlmValidation: settings.vlmValidationEnabled,
|
enableVlmValidation: vlmValidationEnabled,
|
||||||
sessionId: langfuseSessionId,
|
sessionId: langfuseSessionId,
|
||||||
watchStop: () => {
|
watchStop: () => {
|
||||||
const stopsBefore = stopCountRef.current
|
const stopsBefore = stopCountRef.current
|
||||||
@@ -482,9 +424,40 @@ export function ChatEngineProvider({
|
|||||||
onDisplayChart(original, true, "revert")
|
onDisplayChart(original, true, "revert")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requests made in the turn so far. The first carries the diagram the
|
||||||
|
// message was sent with (exported then, or the snapshot it is sent
|
||||||
|
// again from). Each later one, an automatic retry after a tool error,
|
||||||
|
// carries the canvas as it is then: a drawing is committed before its
|
||||||
|
// screenshot check can fail, and the model must go on from that
|
||||||
|
// drawing, not from the one before the turn.
|
||||||
|
const turnRequestsRef = useRef(0)
|
||||||
const transport = useMemo(
|
const transport = useMemo(
|
||||||
() => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }),
|
() =>
|
||||||
[],
|
new DefaultChatTransport<ChatUIMessage>({
|
||||||
|
api: getApiEndpoint("/api/chat"),
|
||||||
|
prepareSendMessagesRequest: ({
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
body,
|
||||||
|
trigger,
|
||||||
|
messageId,
|
||||||
|
}) => ({
|
||||||
|
body: {
|
||||||
|
...body,
|
||||||
|
...(turnRequestsRef.current++ > 0 && {
|
||||||
|
xml: getPageXml(
|
||||||
|
chartXMLRef.current,
|
||||||
|
turnPageIdRef.current,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
id,
|
||||||
|
messages,
|
||||||
|
trigger,
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[chartXMLRef],
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -495,19 +468,19 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
setMessages,
|
setMessages,
|
||||||
stop,
|
stop,
|
||||||
} = useChat({
|
} = useChat<ChatUIMessage>({
|
||||||
transport,
|
transport,
|
||||||
onToolCall: async ({ toolCall }) => {
|
onToolCall: async ({ toolCall }) => {
|
||||||
await handleToolCall({ toolCall }, addToolOutputSafely)
|
await handleToolCall({ toolCall }, addToolOutputSafely)
|
||||||
},
|
},
|
||||||
onFinish: ({ message, isAbort, isError }) => {
|
onFinish: ({ message, isAbort, isError, finishReason }) => {
|
||||||
// Stopped, failed, or the reply just ended (a closed connection):
|
// Stopped, failed, or the reply just ended (a closed connection):
|
||||||
// tool calls still streaming never reach the tool handler. Mark
|
// tool calls still streaming never reach the tool handler. Mark
|
||||||
// them handled so a later render of the stream does not draw
|
// them handled so a later render of the stream does not draw
|
||||||
// their preview again.
|
// their preview again.
|
||||||
const cut = (message.parts as any[]).filter(
|
const cut = message.parts
|
||||||
(part) => part.state === "input-streaming" && part.toolCallId,
|
.filter(isToolUIPart)
|
||||||
)
|
.filter((part) => part.state === "input-streaming")
|
||||||
for (const part of cut) {
|
for (const part of cut) {
|
||||||
processedToolCallsRef.current.add(part.toolCallId)
|
processedToolCallsRef.current.add(part.toolCallId)
|
||||||
}
|
}
|
||||||
@@ -516,18 +489,16 @@ export function ChatEngineProvider({
|
|||||||
// or the page going, aborts a reply). Not once another chat is
|
// or the page going, aborts a reply). Not once another chat is
|
||||||
// on screen: the call is not among its messages.
|
// on screen: the call is not among its messages.
|
||||||
if (isAbort && getChatGeneration() === turnChatRef.current) {
|
if (isAbort && getChatGeneration() === turnChatRef.current) {
|
||||||
for (const part of cut) {
|
for (const part of cut) stopToolCall(part)
|
||||||
addToolOutputSafely({
|
|
||||||
tool: part.type.replace("tool-", ""),
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: "Stopped by user",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isAbort || isError || cut.length === 0) return
|
if (isAbort || isError) return
|
||||||
// Ended with no error: undo the preview, and say so, as an
|
// The output budget ran out before the model called a tool (its
|
||||||
// error does (with Retry)
|
// thinking took it all): nothing was drawn
|
||||||
|
const outOfTokens =
|
||||||
|
finishReason === "length" && !message.parts.some(isToolUIPart)
|
||||||
|
if (cut.length === 0 && !outOfTokens) return
|
||||||
|
// Ended with no error and no diagram: undo the preview, and say
|
||||||
|
// so, as an error does (with Retry)
|
||||||
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
const [originalXml] = editDiagramOriginalXmlRef.current.values()
|
||||||
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
if (originalXml) onDisplayChart(originalXml, true, "revert")
|
||||||
editDiagramOriginalXmlRef.current.clear()
|
editDiagramOriginalXmlRef.current.clear()
|
||||||
@@ -536,16 +507,11 @@ export function ChatEngineProvider({
|
|||||||
if (getChatGeneration() !== turnChatRef.current) return
|
if (getChatGeneration() !== turnChatRef.current) return
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
{
|
noticeMessage({
|
||||||
id: `error-${Date.now()}`,
|
text: outOfTokens
|
||||||
role: "system" as const,
|
? dict.errors.llm.output_truncated
|
||||||
parts: [
|
: dict.errors.answerEndedEarly,
|
||||||
{
|
}),
|
||||||
type: "text" as const,
|
|
||||||
text: dict.errors.answerEndedEarly,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -614,19 +580,15 @@ export function ChatEngineProvider({
|
|||||||
// the model selected may have changed since, e.g. in another tab
|
// the model selected may have changed since, e.g. in another tab
|
||||||
const providerId = requestProviderIdRef.current
|
const providerId = requestProviderIdRef.current
|
||||||
|
|
||||||
// A system message, so it can be cleared with the conversation
|
// In the chat, so it is cleared with the conversation; with a
|
||||||
|
// button that opens model settings, on the page of the provider
|
||||||
|
// that refused
|
||||||
setMessages((currentMessages) => [
|
setMessages((currentMessages) => [
|
||||||
...currentMessages,
|
...currentMessages,
|
||||||
{
|
noticeMessage({
|
||||||
id: `error-${Date.now()}`,
|
text,
|
||||||
role: "system" as const,
|
...(openModelConfig && { openModelConfig, providerId }),
|
||||||
parts: [{ type: "text" as const, text }],
|
}),
|
||||||
// The message shows a button that opens model settings,
|
|
||||||
// on the page of the provider that refused
|
|
||||||
...(openModelConfig && {
|
|
||||||
metadata: { openModelConfig: true, providerId },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (isAccessCodeError) openSettings("general")
|
if (isAccessCodeError) openSettings("general")
|
||||||
@@ -656,9 +618,19 @@ export function ChatEngineProvider({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A call the user stopped before it reached the tool handler. The SDK
|
||||||
|
// finds the call by its id; the tool name is for the types only.
|
||||||
|
const stopToolCall = (part: Parameters<typeof getToolName>[0]) =>
|
||||||
|
addToolOutputSafely({
|
||||||
|
tool: getToolName(part) as ChatToolName,
|
||||||
|
toolCallId: part.toolCallId,
|
||||||
|
state: "output-error",
|
||||||
|
errorText: "Stopped by user",
|
||||||
|
})
|
||||||
|
|
||||||
// After each request: send the next one (retry, continue a cut off
|
// After each request: send the next one (retry, continue a cut off
|
||||||
// drawing) or end the turn
|
// drawing) or end the turn
|
||||||
const shouldSendAgain = (messages: UIMessage[]) => {
|
const shouldSendAgain = (messages: ChatUIMessage[]) => {
|
||||||
// The user stopped: a tool result that arrives later (a VLM
|
// The user stopped: a tool result that arrives later (a VLM
|
||||||
// check still running) must not start a new request
|
// check still running) must not start a new request
|
||||||
if (stoppedRef.current) return false
|
if (stoppedRef.current) return false
|
||||||
@@ -710,12 +682,14 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBusy = status === "streaming" || status === "submitted"
|
const isBusy = status === "streaming" || status === "submitted"
|
||||||
useEffect(() => {
|
|
||||||
if (messages.length > 0) setIsResending(false)
|
|
||||||
}, [messages.length])
|
|
||||||
// isBusy of the latest render, for code that runs after an await
|
// isBusy of the latest render, for code that runs after an await
|
||||||
const busyRef = useRef(isBusy)
|
const busyRef = useRef(isBusy)
|
||||||
busyRef.current = isBusy
|
busyRef.current = isBusy
|
||||||
|
// Canvas components (version cards, the compare dialog, "ask AI") read
|
||||||
|
// the busy flag from the canvas store
|
||||||
|
useEffect(() => {
|
||||||
|
useCanvasStore.getState().set({ isBusy })
|
||||||
|
}, [isBusy])
|
||||||
// The page goes (another language mounts a new one): the answer stops,
|
// The page goes (another language mounts a new one): the answer stops,
|
||||||
// so it cannot reach the next page's canvas
|
// so it cannot reach the next page's canvas
|
||||||
const stopRef = useRef(stop)
|
const stopRef = useRef(stop)
|
||||||
@@ -748,6 +722,7 @@ export function ChatEngineProvider({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
turnPageIdRef,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { restoreVersion, undoVersion } = useVersions({
|
const { restoreVersion, undoVersion } = useVersions({
|
||||||
@@ -762,14 +737,10 @@ export function ChatEngineProvider({
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
const selection = useCanvasStore((s) => s.selection)
|
const selection = useCanvasStore((s) => s.selection)
|
||||||
// The model sees and edits the first page only
|
|
||||||
const onFirstPage = useCanvasStore(
|
|
||||||
(s) => s.pages.length === 0 || s.pages[0]?.id === s.currentPageId,
|
|
||||||
)
|
|
||||||
const [dismissedSelectionKey, setDismissedSelectionKey] = useState("")
|
const [dismissedSelectionKey, setDismissedSelectionKey] = useState("")
|
||||||
const selectionKey = selection.map((c) => c.id).join(",")
|
const selectionKey = selection.map((c) => c.id).join(",")
|
||||||
const chatSelection =
|
const chatSelection =
|
||||||
!onFirstPage || selectionKey === dismissedSelectionKey ? [] : selection
|
selectionKey === dismissedSelectionKey ? [] : selection
|
||||||
// Once nothing is selected, picking the same shapes again attaches them
|
// Once nothing is selected, picking the same shapes again attaches them
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selection.length === 0) setDismissedSelectionKey("")
|
if (selection.length === 0) setDismissedSelectionKey("")
|
||||||
@@ -822,16 +793,10 @@ export function ChatEngineProvider({
|
|||||||
const [shownChats, setShownChats] = useState(0)
|
const [shownChats, setShownChats] = useState(0)
|
||||||
// The messages shown when another chat was put on screen: useChat can
|
// The messages shown when another chat was put on screen: useChat can
|
||||||
// show that chat's own up to 150 ms later than the rest of its state
|
// show that chat's own up to 150 ms later than the rest of its state
|
||||||
const messagesBeforeShownRef = useRef<UIMessage[] | null>(null)
|
const messagesBeforeShownRef = useRef<ChatUIMessage[] | null>(null)
|
||||||
|
|
||||||
const syncUIWithSession = useCallback(
|
const syncUIWithSession = useCallback(
|
||||||
(
|
(data: SessionData | null) => {
|
||||||
data:
|
|
||||||
| (SessionData & {
|
|
||||||
diagramHistory?: { svg: string; xml: string }[]
|
|
||||||
})
|
|
||||||
| null,
|
|
||||||
) => {
|
|
||||||
// An answer still coming in belongs to the chat that was on
|
// An answer still coming in belongs to the chat that was on
|
||||||
// screen (browser back or forward): it stops, before this chat's
|
// screen (browser back or forward): it stops, before this chat's
|
||||||
// messages are put in its place, so none of it lands here
|
// messages are put in its place, so none of it lands here
|
||||||
@@ -868,26 +833,17 @@ export function ChatEngineProvider({
|
|||||||
loadedMessageIdsRef.current = new Set(
|
loadedMessageIdsRef.current = new Set(
|
||||||
(data.messages as { id: string }[]).map((m) => m.id),
|
(data.messages as { id: string }[]).map((m) => m.id),
|
||||||
)
|
)
|
||||||
setMessages(data.messages as unknown as UIMessage[])
|
setMessages(data.messages as unknown as ChatUIMessage[])
|
||||||
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||||
if (data.diagramXml) {
|
if (data.diagramXml) onDisplayChart(data.diagramXml, true)
|
||||||
onDisplayChart(data.diagramXml, true)
|
else clearDiagram()
|
||||||
chartXMLRef.current = data.diagramXml
|
|
||||||
} else {
|
|
||||||
clearDiagram()
|
|
||||||
chartXMLRef.current = ""
|
|
||||||
}
|
|
||||||
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
|
||||||
useVersionsStore
|
useVersionsStore.getState().setVersions(data.versions ?? [])
|
||||||
.getState()
|
|
||||||
.setVersions(getSessionVersions(data))
|
|
||||||
} else {
|
} else {
|
||||||
loadedMessageIdsRef.current = new Set()
|
loadedMessageIdsRef.current = new Set()
|
||||||
setMessages([])
|
setMessages([])
|
||||||
xmlSnapshotsRef.current.clear()
|
xmlSnapshotsRef.current.clear()
|
||||||
clearDiagram()
|
clearDiagram()
|
||||||
chartXMLRef.current = ""
|
|
||||||
latestSvgRef.current = ""
|
|
||||||
useVersionsStore.getState().clear()
|
useVersionsStore.getState().clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -920,7 +876,6 @@ export function ChatEngineProvider({
|
|||||||
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
|
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
|
||||||
const freshThumb = await getThumbnailSvg()
|
const freshThumb = await getThumbnailSvg()
|
||||||
if (freshThumb) {
|
if (freshThumb) {
|
||||||
latestSvgRef.current = freshThumb
|
|
||||||
thumbnailDataUrl = freshThumb
|
thumbnailDataUrl = freshThumb
|
||||||
} else if (latestSvgRef.current) {
|
} else if (latestSvgRef.current) {
|
||||||
thumbnailDataUrl = latestSvgRef.current
|
thumbnailDataUrl = latestSvgRef.current
|
||||||
@@ -1178,18 +1133,22 @@ export function ChatEngineProvider({
|
|||||||
.filter((k) => k < beforeIndex)
|
.filter((k) => k < beforeIndex)
|
||||||
.sort((a, b) => b - a)
|
.sort((a, b) => b - a)
|
||||||
return snapshotKeys.length > 0
|
return snapshotKeys.length > 0
|
||||||
? getFirstPageXml(
|
? getPageXml(
|
||||||
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
||||||
|
viewedPageId(),
|
||||||
)
|
)
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messageId: the user message sent again (regenerate, edit, retry); the
|
||||||
|
// SDK puts the new one in its place and drops the answers after it
|
||||||
const sendChatMessage = (
|
const sendChatMessage = (
|
||||||
parts: any,
|
parts: any,
|
||||||
xml: string,
|
xml: string,
|
||||||
previousXml: string,
|
previousXml: string,
|
||||||
turnIndex: number,
|
turnIndex: number,
|
||||||
selectedCells: MessageSelection,
|
selectedCells: MessageSelection,
|
||||||
|
messageId?: string,
|
||||||
) => {
|
) => {
|
||||||
autoRetryCountRef.current = 0
|
autoRetryCountRef.current = 0
|
||||||
continuationRetryCountRef.current = 0
|
continuationRetryCountRef.current = 0
|
||||||
@@ -1198,6 +1157,7 @@ export function ChatEngineProvider({
|
|||||||
continuationOriginalRef.current = null
|
continuationOriginalRef.current = null
|
||||||
stoppedRef.current = false
|
stoppedRef.current = false
|
||||||
currentTurnRef.current = turnIndex
|
currentTurnRef.current = turnIndex
|
||||||
|
turnPageIdRef.current = viewedPageId()
|
||||||
turnChatRef.current = getChatGeneration()
|
turnChatRef.current = getChatGeneration()
|
||||||
// Busy from now on, before the next render says so
|
// Busy from now on, before the next render says so
|
||||||
busyRef.current = true
|
busyRef.current = true
|
||||||
@@ -1226,9 +1186,11 @@ export function ChatEngineProvider({
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
turnRequestsRef.current = 0
|
||||||
// The selection stays with the message, for regenerate and retry
|
// The selection stays with the message, for regenerate and retry
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{
|
{
|
||||||
|
...(messageId !== undefined && { messageId }),
|
||||||
parts,
|
parts,
|
||||||
...(selected.length > 0 && {
|
...(selected.length > 0 && {
|
||||||
metadata: { selectedCells: selected },
|
metadata: { selectedCells: selected },
|
||||||
@@ -1247,14 +1209,15 @@ export function ChatEngineProvider({
|
|||||||
selectedCells: MessageSelection = [],
|
selectedCells: MessageSelection = [],
|
||||||
generation = getChatGeneration(),
|
generation = getChatGeneration(),
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const chartXml = formatXML(await onFetchChart())
|
const chartXml = formatXML(await fetchDiagramXml())
|
||||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const turnIndex = messagesRef.current.length
|
const turnIndex = messagesRef.current.length
|
||||||
const previousXml = getPreviousXml(turnIndex)
|
const previousXml = getPreviousXml(turnIndex)
|
||||||
// Snapshot the full multi-page document (kept fresh by autosave) so
|
// Snapshot the full multi-page document (kept fresh by autosave) so
|
||||||
// regenerate/edit can restore every page; the model gets page 1 only
|
// regenerate/edit can restore every page; the model gets the page
|
||||||
|
// the user is viewing
|
||||||
xmlSnapshotsRef.current.set(turnIndex, chartXMLRef.current || chartXml)
|
xmlSnapshotsRef.current.set(turnIndex, chartXMLRef.current || chartXml)
|
||||||
sendChatMessage(parts, chartXml, previousXml, turnIndex, selectedCells)
|
sendChatMessage(parts, chartXml, previousXml, turnIndex, selectedCells)
|
||||||
return true
|
return true
|
||||||
@@ -1328,11 +1291,12 @@ export function ChatEngineProvider({
|
|||||||
pageName: "Page-1",
|
pageName: "Page-1",
|
||||||
})
|
})
|
||||||
if (prepared.ok) {
|
if (prepared.ok) {
|
||||||
|
const pageId = viewedPageId()
|
||||||
onDisplayChart(
|
onDisplayChart(
|
||||||
keepFileVars(prepared.xml, chartXMLRef.current || ""),
|
placeOnPage(prepared.xml, chartXMLRef.current, pageId),
|
||||||
true,
|
true,
|
||||||
"commit",
|
"commit",
|
||||||
{ toolCallId },
|
{ toolCallId, pageId },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
clearComposer()
|
clearComposer()
|
||||||
@@ -1417,8 +1381,8 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Templates fill the input and send it right away (attachments included)
|
// Templates fill the input and send it right away (attachments included)
|
||||||
// Whether it was sent (it can run after a wait: the template panel
|
// Whether it was sent (it can run after a wait, so busy is read from
|
||||||
// stores its click count first, so busy is read from the ref)
|
// the ref)
|
||||||
const sendTemplate = async (prompt: string): Promise<boolean> => {
|
const sendTemplate = async (prompt: string): Promise<boolean> => {
|
||||||
// Like the send button, it waits for an answer or a message being
|
// Like the send button, it waits for an answer or a message being
|
||||||
// prepared (that message may still come back to the input), and for
|
// prepared (that message may still come back to the input), and for
|
||||||
@@ -1458,11 +1422,6 @@ export function ChatEngineProvider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreDiagramFromSnapshot = (savedXml: string) => {
|
|
||||||
onDisplayChart(savedXml, true)
|
|
||||||
chartXMLRef.current = savedXml
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshots of the messages after this one go with them
|
// Snapshots of the messages after this one go with them
|
||||||
const dropLaterSnapshots = (messageIndex: number) => {
|
const dropLaterSnapshots = (messageIndex: number) => {
|
||||||
for (const key of xmlSnapshotsRef.current.keys()) {
|
for (const key of xmlSnapshotsRef.current.keys()) {
|
||||||
@@ -1480,22 +1439,17 @@ export function ChatEngineProvider({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const previousXml = getPreviousXml(index)
|
const previousXml = getPreviousXml(index)
|
||||||
restoreDiagramFromSnapshot(savedXml)
|
onDisplayChart(savedXml, true)
|
||||||
dropLaterSnapshots(index)
|
dropLaterSnapshots(index)
|
||||||
useVersionsStore.getState().removeFromTurn(index)
|
useVersionsStore.getState().removeFromTurn(index)
|
||||||
|
|
||||||
// Remove the user message onwards (sendMessage re-adds it). For a
|
|
||||||
// moment there may be no message: that is no empty chat.
|
|
||||||
flushSync(() => {
|
|
||||||
setIsResending(true)
|
|
||||||
setMessages(messages.slice(0, index))
|
|
||||||
})
|
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
parts,
|
parts,
|
||||||
getFirstPageXml(savedXml),
|
getPageXml(savedXml, viewedPageId()),
|
||||||
previousXml,
|
previousXml,
|
||||||
index,
|
index,
|
||||||
selectionOf(messages[index]),
|
selectionOf(messages[index]),
|
||||||
|
messages[index].id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1548,7 +1502,7 @@ export function ChatEngineProvider({
|
|||||||
// Like submit: the diagram export comes first
|
// Like submit: the diagram export comes first
|
||||||
preparingSendRef.current = true
|
preparingSendRef.current = true
|
||||||
try {
|
try {
|
||||||
const chartXml = formatXML(await onFetchChart())
|
const chartXml = formatXML(await fetchDiagramXml())
|
||||||
// Another chat came on screen meanwhile (browser back/forward),
|
// Another chat came on screen meanwhile (browser back/forward),
|
||||||
// or this one is being left
|
// or this one is being left
|
||||||
if (getChatGeneration() !== generation || leavingRef.current) {
|
if (getChatGeneration() !== generation || leavingRef.current) {
|
||||||
@@ -1561,16 +1515,13 @@ export function ChatEngineProvider({
|
|||||||
chartXMLRef.current || chartXml,
|
chartXMLRef.current || chartXml,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
flushSync(() => {
|
|
||||||
setIsResending(true)
|
|
||||||
setMessages(messagesRef.current.slice(0, index))
|
|
||||||
})
|
|
||||||
sendChatMessage(
|
sendChatMessage(
|
||||||
message.parts,
|
message.parts,
|
||||||
chartXml,
|
chartXml,
|
||||||
getPreviousXml(index),
|
getPreviousXml(index),
|
||||||
index,
|
index,
|
||||||
selectionOf(message),
|
selectionOf(message),
|
||||||
|
message.id,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching chart data:", error)
|
console.error("Error fetching chart data:", error)
|
||||||
@@ -1589,25 +1540,17 @@ export function ChatEngineProvider({
|
|||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
// Calls the tool handler already took can still show as streaming:
|
// Calls the tool handler already took can still show as streaming:
|
||||||
// the messages update at most every 150 ms (useChat throttle)
|
// the messages update at most every 150 ms (useChat throttle)
|
||||||
const toolParts = lastMessage?.parts?.filter(
|
const streaming = (lastMessage?.parts ?? [])
|
||||||
(part: any) =>
|
.filter(isToolUIPart)
|
||||||
part.type?.startsWith("tool-") &&
|
.filter(
|
||||||
part.state === "input-streaming" &&
|
(part) =>
|
||||||
!processedToolCallsRef.current.has(part.toolCallId),
|
part.state === "input-streaming" &&
|
||||||
)
|
!processedToolCallsRef.current.has(part.toolCallId),
|
||||||
for (const part of (toolParts ?? []) as any[]) {
|
)
|
||||||
if (part.toolCallId) {
|
for (const part of streaming) stopToolCall(part)
|
||||||
addToolOutputSafely({
|
|
||||||
tool: part.type.replace("tool-", ""),
|
|
||||||
toolCallId: part.toolCallId,
|
|
||||||
state: "output-error",
|
|
||||||
errorText: "Stopped by user",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stop()
|
stop()
|
||||||
abandonContinuation()
|
abandonContinuation()
|
||||||
}, [messages, addToolOutputSafely, stop, cancelValidation])
|
}, [messages, stopToolCall, stop, cancelValidation])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Session actions
|
// Session actions
|
||||||
@@ -1917,8 +1860,7 @@ export function ChatEngineProvider({
|
|||||||
error,
|
error,
|
||||||
isBusy,
|
isBusy,
|
||||||
isLeaving,
|
isLeaving,
|
||||||
isResending,
|
setMessages: setMessages as (messages: ChatUIMessage[]) => void,
|
||||||
setMessages: setMessages as (messages: UIMessage[]) => void,
|
|
||||||
stop: handleStop,
|
stop: handleStop,
|
||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useRef } from "react"
|
import { useRef } from "react"
|
||||||
|
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||||
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
||||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||||
import { IconButton } from "@/components/workspace/icon-button"
|
import { IconButton } from "@/components/workspace/icon-button"
|
||||||
@@ -21,11 +22,11 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
} from "@/stores/settings-store"
|
} from "@/stores/settings-store"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
import { useChatEngine } from "./chat-engine"
|
import { useChatEngine } from "./chat-engine"
|
||||||
import { Composer } from "./composer"
|
import { Composer } from "./composer"
|
||||||
import { MessageList } from "./message-list"
|
import { MessageList } from "./message-list"
|
||||||
import { SessionMenu, useSessionTitle } from "./session-menu"
|
import { SessionMenu, useSessionTitle } from "./session-menu"
|
||||||
import { VersionStrip } from "./version-strip"
|
|
||||||
|
|
||||||
const DEBUG = process.env.NODE_ENV === "development"
|
const DEBUG = process.env.NODE_ENV === "development"
|
||||||
|
|
||||||
@@ -97,6 +98,25 @@ export function ChatPanel({
|
|||||||
const openSettings = useUiStore((s) => s.openSettings)
|
const openSettings = useUiStore((s) => s.openSettings)
|
||||||
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
||||||
|
|
||||||
|
// Versions with no card in the chat: saved by older app versions, from
|
||||||
|
// a turn that was retried, or drawn by a call that then failed its
|
||||||
|
// screenshot check (a call still running gets its card when done). The
|
||||||
|
// strip shows them even when there is only one.
|
||||||
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
|
const hasCardless = versions.some(
|
||||||
|
(v) =>
|
||||||
|
!v.toolCallId ||
|
||||||
|
!engine.messages.some((m) =>
|
||||||
|
m.parts?.some((p) => {
|
||||||
|
const part = p as { toolCallId?: string; state?: string }
|
||||||
|
return (
|
||||||
|
part.toolCallId === v.toolCallId &&
|
||||||
|
part.state !== "output-error"
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -160,7 +180,7 @@ export function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<VersionStrip />
|
<VersionStrip minVersions={hasCardless ? 1 : 2} />
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
VersionsProvider,
|
||||||
|
type VersionsSource,
|
||||||
|
} from "@/components/canvas/versions-context"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
|
import { useChatEngine } from "./chat-engine"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat's versions for the canvas components (version cards, the strip,
|
||||||
|
* the compare dialog): the version store's list and flags, and the chat
|
||||||
|
* engine's restore and undo
|
||||||
|
*/
|
||||||
|
export function ChatVersionsProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { restoreVersion, undoVersion } = useChatEngine()
|
||||||
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
|
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||||
|
const undoneId = useVersionsStore((s) => s.undoneVersionId)
|
||||||
|
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||||
|
|
||||||
|
// Only the latest version can be undone, and only while it is (or was)
|
||||||
|
// on the canvas: after an older version was restored, the latest one
|
||||||
|
// is restored too
|
||||||
|
const latest = versions.at(-1)
|
||||||
|
const undoable = !!latest && latest.beforeXml !== undefined
|
||||||
|
const value: VersionsSource = {
|
||||||
|
versions,
|
||||||
|
onCanvasId,
|
||||||
|
undoneId,
|
||||||
|
isBusy,
|
||||||
|
canUndo: undoable && latest.id === onCanvasId,
|
||||||
|
canRedo: undoable && latest.id === undoneId,
|
||||||
|
restore: restoreVersion,
|
||||||
|
// The engine's undo toggles: it redoes a change that is undone
|
||||||
|
undo: () => latest && undoVersion(latest.id),
|
||||||
|
redo: () => latest && undoVersion(latest.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VersionsProvider value={value}>{children}</VersionsProvider>
|
||||||
|
}
|
||||||
@@ -18,9 +18,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||||
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
|
||||||
import { FilePreviewList } from "@/components/file-preview-list"
|
import { FilePreviewList } from "@/components/file-preview-list"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
import {
|
import {
|
||||||
@@ -46,24 +45,16 @@ import { useUiStore } from "@/stores/ui-store"
|
|||||||
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
|
||||||
import { useChatEngine } from "./chat-engine"
|
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) {
|
function showValidationErrors(errors: string[], dict: any) {
|
||||||
if (errors.length === 0) return
|
if (errors.length === 0) return
|
||||||
if (errors.length === 1) {
|
if (errors.length === 1) {
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<span className="text-muted-foreground">{errors[0]}</span>,
|
<span className="text-muted-foreground">{errors[0]}</span>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatMessage(dict.errors.filesRejected, {
|
{formatMessage(dict.errors.filesRejected, {
|
||||||
@@ -83,6 +74,7 @@ function showValidationErrors(errors: string[], dict: any) {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>,
|
</div>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,10 +269,11 @@ export function Composer({
|
|||||||
next.delete(url)
|
next.delete(url)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
showErrorToast(
|
toast.error(
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{err instanceof Error ? err.message : dict.url.failed}
|
{err instanceof Error ? err.message : dict.url.failed}
|
||||||
</span>,
|
</span>,
|
||||||
|
{ duration: 5000 },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsExtractingUrl(false)
|
setIsExtractingUrl(false)
|
||||||
@@ -519,7 +512,7 @@ export function Composer({
|
|||||||
onSubmit={handleUrlExtract}
|
onSubmit={handleUrlExtract}
|
||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open={saveTemplateOpen}
|
open={saveTemplateOpen}
|
||||||
onOpenChange={setSaveTemplateOpen}
|
onOpenChange={setSaveTemplateOpen}
|
||||||
onSuccess={() => setSaveTemplateOpen(false)}
|
onSuccess={() => setSaveTemplateOpen(false)}
|
||||||
@@ -532,10 +525,6 @@ export function Composer({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
|
||||||
<TemplatePanel
|
<TemplatePanel
|
||||||
setInput={(value) => {
|
|
||||||
setInput(value)
|
|
||||||
setTemplatesOpen(false)
|
|
||||||
}}
|
|
||||||
currentInput={input}
|
currentInput={input}
|
||||||
onSendTemplate={(template) => {
|
onSendTemplate={(template) => {
|
||||||
setTemplatesOpen(false)
|
setTemplatesOpen(false)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileText, FileUp, History, Pencil } from "lucide-react"
|
import { FileUp, History, Pencil } from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getAssetUrl } from "@/lib/base-path"
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
@@ -12,7 +12,7 @@ interface Example {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
/** Picture of the result (or of the input, for "replicate" examples) */
|
/** Picture of the result (or of the input, for "replicate" examples) */
|
||||||
image?: string
|
image: string
|
||||||
prompt: string
|
prompt: string
|
||||||
/** Bundled file sent with the prompt; matches the cached example */
|
/** Bundled file sent with the prompt; matches the cached example */
|
||||||
file?: { path: string; name: string; type: string }
|
file?: { path: string; name: string; type: string }
|
||||||
@@ -34,7 +34,6 @@ function ExampleTile({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
picture,
|
picture,
|
||||||
plain,
|
|
||||||
testId,
|
testId,
|
||||||
}: {
|
}: {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -42,8 +41,6 @@ function ExampleTile({
|
|||||||
/** Shown as the tooltip: the title alone names the example */
|
/** Shown as the tooltip: the title alone names the example */
|
||||||
description: string
|
description: string
|
||||||
picture: React.ReactNode
|
picture: React.ReactNode
|
||||||
/** An icon on a tinted tile instead of a diagram on paper */
|
|
||||||
plain?: boolean
|
|
||||||
testId?: string
|
testId?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -54,12 +51,7 @@ function ExampleTile({
|
|||||||
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
|
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<span
|
<span className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
|
||||||
className={cn(
|
|
||||||
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
|
|
||||||
plain ? "bg-muted" : "sheet-light bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{picture}
|
{picture}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
|
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
|
||||||
@@ -220,18 +212,13 @@ export function LobbyHero({
|
|||||||
description={example.description}
|
description={example.description}
|
||||||
testId={`example-${example.key}`}
|
testId={`example-${example.key}`}
|
||||||
picture={
|
picture={
|
||||||
example.image ? (
|
// biome-ignore lint/performance/noImgElement: static preview
|
||||||
// biome-ignore lint/performance/noImgElement: static preview
|
<img
|
||||||
<img
|
src={getAssetUrl(example.image)}
|
||||||
src={getAssetUrl(example.image)}
|
alt=""
|
||||||
alt=""
|
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
||||||
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FileText className="size-5 text-muted-foreground" />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
plain={!example.image}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UIMessage } from "ai"
|
import { isToolUIPart, type UIMessage } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
BookmarkPlus,
|
BookmarkPlus,
|
||||||
@@ -16,18 +16,15 @@ import {
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
import ReactMarkdown from "react-markdown"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import { Reasoning } from "@/components/ai-elements/reasoning"
|
||||||
Reasoning,
|
|
||||||
ReasoningContent,
|
|
||||||
ReasoningTrigger,
|
|
||||||
} from "@/components/ai-elements/reasoning"
|
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
|
||||||
import type { ToolPartLike } from "@/components/chat/types"
|
import type { ToolPartLike } from "@/components/chat/types"
|
||||||
import { ValidationCard } from "@/components/chat/ValidationCard"
|
import { ValidationCard } from "@/components/chat/ValidationCard"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { noticeOf } from "@/lib/chat-message"
|
||||||
import { useCopy } from "@/lib/clipboard"
|
import { useCopy } from "@/lib/clipboard"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
import { useUiStore } from "@/stores/ui-store"
|
||||||
@@ -291,7 +288,7 @@ function UserMessage({
|
|||||||
{text && <MessageText text={text} />}
|
{text && <MessageText text={text} />}
|
||||||
</div>
|
</div>
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<TemplateCreateDialog
|
<TemplateFormDialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
onOpenChange={(open) => !open && setSaveTemplate(false)}
|
||||||
onSuccess={() => setSaveTemplate(false)}
|
onSuccess={() => setSaveTemplate(false)}
|
||||||
@@ -373,7 +370,7 @@ function AssistantMessage({
|
|||||||
parts.forEach((part, partIndex) => {
|
parts.forEach((part, partIndex) => {
|
||||||
if (part.type === "reasoning")
|
if (part.type === "reasoning")
|
||||||
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
|
||||||
else if (part.type?.startsWith("tool-"))
|
else if (isToolUIPart(part))
|
||||||
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
blocks.push({ kind: "tool", index: partIndex, texts: [] })
|
||||||
else if (part.type === "text") {
|
else if (part.type === "text") {
|
||||||
const text = (part as { text: string }).text
|
const text = (part as { text: string }).text
|
||||||
@@ -398,13 +395,9 @@ function AssistantMessage({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
isStreaming={isStreamingReasoning}
|
isStreaming={isStreamingReasoning}
|
||||||
defaultOpen={!isRestored}
|
defaultOpen={!isRestored}
|
||||||
|
getThinkingMessage={thinkingMessage}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger
|
{(part as { text: string }).text}
|
||||||
getThinkingMessage={thinkingMessage}
|
|
||||||
/>
|
|
||||||
<ReasoningContent>
|
|
||||||
{(part as { text: string }).text}
|
|
||||||
</ReasoningContent>
|
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -576,9 +569,10 @@ export function MessageList() {
|
|||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const lastAssistantIndex = messages
|
// A notice (a failed request) is an assistant message without an answer
|
||||||
.map((m) => m.role)
|
const lastAssistantIndex = messages.findLastIndex(
|
||||||
.lastIndexOf("assistant")
|
(m) => m.role === "assistant" && !noticeOf(m),
|
||||||
|
)
|
||||||
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -587,6 +581,7 @@ export function MessageList() {
|
|||||||
const isRestored = engine.loadedMessageIdsRef.current.has(
|
const isRestored = engine.loadedMessageIdsRef.current.has(
|
||||||
message.id,
|
message.id,
|
||||||
)
|
)
|
||||||
|
const notice = noticeOf(message)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@@ -598,33 +593,18 @@ export function MessageList() {
|
|||||||
index={index}
|
index={index}
|
||||||
isLast={index === lastUserIndex}
|
isLast={index === lastUserIndex}
|
||||||
/>
|
/>
|
||||||
) : message.role === "assistant" ? (
|
) : notice ? (
|
||||||
<AssistantMessage
|
|
||||||
message={message}
|
|
||||||
index={index}
|
|
||||||
isLastMessage={index === messages.length - 1}
|
|
||||||
isLastAssistant={index === lastAssistantIndex}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ErrorNotice
|
<ErrorNotice
|
||||||
text={getMessageTextContent(message)}
|
text={notice.text}
|
||||||
openModelConfig={
|
openModelConfig={!!notice.openModelConfig}
|
||||||
!!(
|
onOpenModelConfig={() =>
|
||||||
message.metadata as
|
|
||||||
| { openModelConfig?: boolean }
|
|
||||||
| undefined
|
|
||||||
)?.openModelConfig
|
|
||||||
}
|
|
||||||
onOpenModelConfig={() => {
|
|
||||||
const { providerId } =
|
|
||||||
(message.metadata as
|
|
||||||
| { providerId?: string }
|
|
||||||
| undefined) ?? {}
|
|
||||||
openSettings(
|
openSettings(
|
||||||
"models",
|
"models",
|
||||||
providerId ? { providerId } : "list",
|
notice.providerId
|
||||||
|
? { providerId: notice.providerId }
|
||||||
|
: "list",
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
onRetry={
|
onRetry={
|
||||||
index === messages.length - 1 &&
|
index === messages.length - 1 &&
|
||||||
!engine.isBusy &&
|
!engine.isBusy &&
|
||||||
@@ -633,6 +613,13 @@ export function MessageList() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<AssistantMessage
|
||||||
|
message={message}
|
||||||
|
index={index}
|
||||||
|
isLastMessage={index === messages.length - 1}
|
||||||
|
isLastAssistant={index === lastAssistantIndex}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getToolName } from "ai"
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
@@ -5,19 +6,14 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Library,
|
Library,
|
||||||
PenTool,
|
PenTool,
|
||||||
Redo2,
|
|
||||||
RotateCcw,
|
|
||||||
Undo2,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||||
|
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
|
||||||
import { CodeBlock } from "@/components/code-block"
|
import { CodeBlock } from "@/components/code-block"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { useCopy } from "@/lib/clipboard"
|
import { useCopy } from "@/lib/clipboard"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
|
||||||
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
||||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
|
||||||
import { useUiStore } from "@/stores/ui-store"
|
|
||||||
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
||||||
import { useChatEngine } from "./chat-engine"
|
import { useChatEngine } from "./chat-engine"
|
||||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||||
@@ -83,45 +79,9 @@ function toolText(part: ToolPartLike): string {
|
|||||||
return JSON.stringify(input, null, 2)
|
return JSON.stringify(input, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
|
||||||
export function VersionThumb({
|
|
||||||
svg,
|
|
||||||
dimmed,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
/** undefined or null: still being made; "": none could be made */
|
|
||||||
svg: string | null | undefined
|
|
||||||
dimmed?: boolean
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const t = useDictionary().versions
|
|
||||||
if (svg) {
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
|
||||||
<img
|
|
||||||
src={svg}
|
|
||||||
alt=""
|
|
||||||
className={cn(
|
|
||||||
"object-contain transition-opacity",
|
|
||||||
dimmed && "opacity-40",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (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
|
* Card for one diagram version produced by a tool call: the shared card
|
||||||
* its picture; older ones fold into one row (the picture opens Compare).
|
* with a link to what the AI wrote (shown in a panel under the card)
|
||||||
*/
|
*/
|
||||||
function VersionCard({
|
function VersionCard({
|
||||||
part,
|
part,
|
||||||
@@ -134,175 +94,49 @@ function VersionCard({
|
|||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const t = dict.versions
|
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 [showCode, setShowCode] = useState(false)
|
||||||
const { copied, copy } = useCopy()
|
const { copied, copy } = useCopy()
|
||||||
|
|
||||||
const isUndone = undoneVersionId === version.id
|
|
||||||
const isOnCanvas = onCanvasVersionId === version.id
|
|
||||||
const isEdit = part.type === "tool-edit_diagram"
|
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
|
|
||||||
? describeTotals(version.summary, t)
|
|
||||||
: 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{version.number}
|
|
||||||
</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: version.number })}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the AI wrote: a text link in the summary line, so the card's
|
|
||||||
// only button is the action (undo or restore)
|
|
||||||
const summaryLine = (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{summary}
|
|
||||||
{/* The link wraps as one unit with its separator */}
|
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
<span aria-hidden> · </span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCode((v) => !v)}
|
|
||||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
|
||||||
>
|
|
||||||
{showCode ? t.hideCode : t.showCode}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const codePanel = showCode && (
|
|
||||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copy(toolText(part))}
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isLatest) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="overflow-hidden rounded-xl border border-border bg-card"
|
|
||||||
data-testid="version-card"
|
|
||||||
data-tool-state={part.state}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<VersionThumb
|
|
||||||
svg={version.svg}
|
|
||||||
className="max-h-8 max-w-[52px]"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{badge}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-[13px] text-foreground">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{summaryLine}
|
|
||||||
</div>
|
|
||||||
{restoreButton}
|
|
||||||
</div>
|
|
||||||
{codePanel}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<VersionCardView
|
||||||
className={cn(
|
version={version}
|
||||||
"overflow-hidden rounded-xl border border-border bg-card",
|
isLatest={isLatest}
|
||||||
isOnCanvas && "border-foreground/15",
|
label={isEdit ? t.edited : t.created}
|
||||||
)}
|
|
||||||
data-testid="version-card"
|
|
||||||
data-tool-state={part.state}
|
data-tool-state={part.state}
|
||||||
>
|
// A text link, so the card's only button is the action (undo
|
||||||
<button
|
// or restore); it wraps as one unit with its separator
|
||||||
type="button"
|
afterSummary={
|
||||||
onClick={() => openCompare(version.id)}
|
<span className="whitespace-nowrap">
|
||||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
<span aria-hidden> · </span>
|
||||||
aria-label={t.compare}
|
|
||||||
>
|
|
||||||
<VersionThumb
|
|
||||||
svg={version.svg}
|
|
||||||
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>
|
|
||||||
{/* Title row with the action; the summary runs under both */}
|
|
||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
|
||||||
{badge}
|
|
||||||
<div className="truncate text-[13px] font-medium text-foreground">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{canUndo ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={engine.isBusy}
|
onClick={() => setShowCode((v) => !v)}
|
||||||
onClick={() => engine.undoVersion(version.id)}
|
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||||
className={actionClass}
|
|
||||||
data-testid="version-undo"
|
|
||||||
>
|
>
|
||||||
{isUndone ? (
|
{showCode ? t.hideCode : t.showCode}
|
||||||
<Redo2 className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<Undo2 className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{isUndone ? t.redo : t.undo}
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
</span>
|
||||||
restoreButton
|
}
|
||||||
)}
|
>
|
||||||
<div className="col-span-2 col-start-2 mt-0.5">
|
{showCode && (
|
||||||
{summaryLine}
|
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copy(toolText(part))}
|
||||||
|
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>
|
||||||
</div>
|
)}
|
||||||
{codePanel}
|
</VersionCardView>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +165,7 @@ function ToolRow({
|
|||||||
tone === "error" && "border-destructive/25",
|
tone === "error" && "border-destructive/25",
|
||||||
)}
|
)}
|
||||||
data-testid="tool-row"
|
data-testid="tool-row"
|
||||||
data-tool-name={part.type?.replace("tool-", "")}
|
data-tool-name={getToolName(part)}
|
||||||
data-tool-state={part.state}
|
data-tool-state={part.state}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -413,7 +247,7 @@ export function ToolActivity({
|
|||||||
const t = dict.versions
|
const t = dict.versions
|
||||||
const engine = useChatEngine()
|
const engine = useChatEngine()
|
||||||
const versions = useVersionsStore((s) => s.versions)
|
const versions = useVersionsStore((s) => s.versions)
|
||||||
const toolName = part.type?.replace("tool-", "")
|
const toolName = getToolName(part)
|
||||||
const { state, errorText, output } = part
|
const { state, errorText, output } = part
|
||||||
|
|
||||||
const versionIndex = versions.findIndex(
|
const versionIndex = versions.findIndex(
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
|
||||||
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
|
|
||||||
export type { DiagramOperation }
|
export type { DiagramOperation }
|
||||||
|
|
||||||
export interface ToolPartLike {
|
/**
|
||||||
type: string
|
* A tool part as the UI reads it, in any state: input, output and errorText
|
||||||
toolCallId: string
|
* are each there or not, instead of one shape per state.
|
||||||
state?: string
|
*/
|
||||||
input?: {
|
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
|
||||||
xml?: string
|
input?:
|
||||||
operations?: DiagramOperation[]
|
| ({
|
||||||
} & Record<string, unknown>
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>)
|
||||||
|
| undefined
|
||||||
output?: string
|
output?: string
|
||||||
errorText?: string
|
errorText?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,25 @@ import type { RefObject } from "react"
|
|||||||
import { useCallback, useEffect, useRef } from "react"
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import {
|
import { pageSelectorFor, replacePageModel } from "@/lib/diagram-pages"
|
||||||
convertToLegalXml,
|
import { extractCompleteMxCells } from "@/lib/utils"
|
||||||
extractCompleteMxCells,
|
|
||||||
replaceNodes,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import {
|
import {
|
||||||
defaultLayerOf,
|
defaultLayerOf,
|
||||||
expandCompactCells,
|
expandCompactCells,
|
||||||
} from "@/packages/mcp-server/src/compact-cells.ts"
|
} from "@/packages/mcp-server/src/compact-cells.ts"
|
||||||
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
|
||||||
|
import {
|
||||||
|
BLANK_MXFILE,
|
||||||
|
wrapCellsInModel,
|
||||||
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
import {
|
import {
|
||||||
addDefaultStyles,
|
addDefaultStyles,
|
||||||
applyStyleClasses,
|
applyStyleClasses,
|
||||||
edgeIdsOf,
|
edgeIdsOf,
|
||||||
readStyleClasses,
|
readStyleClasses,
|
||||||
} from "@/packages/mcp-server/src/style-classes.ts"
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
import { autoFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -44,6 +46,8 @@ interface UseDiagramStreamingParams {
|
|||||||
processedToolCallsRef: RefObject<Set<string>>
|
processedToolCallsRef: RefObject<Set<string>>
|
||||||
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
|
||||||
loadedMessageIdsRef: RefObject<Set<string>>
|
loadedMessageIdsRef: RefObject<Set<string>>
|
||||||
|
// The page the model draws on in this turn; null: the first page
|
||||||
|
turnPageIdRef?: RefObject<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +64,7 @@ export function useDiagramStreaming({
|
|||||||
processedToolCallsRef,
|
processedToolCallsRef,
|
||||||
editDiagramOriginalXmlRef,
|
editDiagramOriginalXmlRef,
|
||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
|
turnPageIdRef,
|
||||||
}: UseDiagramStreamingParams) {
|
}: UseDiagramStreamingParams) {
|
||||||
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
@@ -86,27 +91,31 @@ export function useDiagramStreaming({
|
|||||||
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
|
||||||
const completeCells = extractCompleteMxCells(cellsXml)
|
const completeCells = extractCompleteMxCells(cellsXml)
|
||||||
if (!completeCells) return
|
if (!completeCells) return
|
||||||
const convertedXml = addDefaultStyles(
|
// The same wrapping, fixes and expansion as the final diagram
|
||||||
|
// (prepareNewDiagram), so the preview matches what is committed
|
||||||
|
const model = addDefaultStyles(
|
||||||
applyStyleClasses(
|
applyStyleClasses(
|
||||||
expandCompactCells(convertToLegalXml(completeCells)),
|
expandCompactCells(
|
||||||
|
autoFixXml(wrapCellsInModel(completeCells)).fixed,
|
||||||
|
),
|
||||||
classes,
|
classes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (convertedXml === previousXML.current) return
|
if (model === previousXML.current) return
|
||||||
|
|
||||||
// Skip this update while the cells written so far don't parse
|
// Skip this update while the cells written so far don't parse
|
||||||
const testDoc = new DOMParser().parseFromString(
|
const testDoc = new DOMParser().parseFromString(model, "text/xml")
|
||||||
`<root>${convertedXml}</root>`,
|
|
||||||
"text/xml",
|
|
||||||
)
|
|
||||||
if (testDoc.querySelector("parsererror")) return
|
if (testDoc.querySelector("parsererror")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Replace the first page's cells so other pages stay intact.
|
// Replace the model's page so other pages stay intact; an
|
||||||
// An empty canvas gets a default mxfile to put the cells in.
|
// empty canvas gets the blank mxfile to put the page in
|
||||||
const baseXML = chartXML || BLANK_MXFILE
|
const replacedXML = replacePageModel(
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
chartXML,
|
||||||
previousXML.current = convertedXml
|
turnPageIdRef?.current,
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
previousXML.current = model
|
||||||
loadDiagram(replacedXML, true, "preview")
|
loadDiagram(replacedXML, true, "preview")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing XML:", error)
|
console.error("Error processing XML:", error)
|
||||||
@@ -220,8 +229,15 @@ export function useDiagramStreaming({
|
|||||||
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
|
||||||
lastProcessedXmlRef.current.set(opsKey, ops)
|
lastProcessedXmlRef.current.set(opsKey, ops)
|
||||||
try {
|
try {
|
||||||
const edges = edgeIdsOf(originalXml)
|
// The model's page: the edges on it and its first layer,
|
||||||
const layer = defaultLayerOf(originalXml)
|
// as editDiagram reads them
|
||||||
|
const selector = pageSelectorFor(
|
||||||
|
originalXml,
|
||||||
|
turnPageIdRef?.current,
|
||||||
|
)
|
||||||
|
const page = targetPageXml(originalXml, selector)
|
||||||
|
const edges = edgeIdsOf(page)
|
||||||
|
const layer = defaultLayerOf(page)
|
||||||
const { result } = applyDiagramOperations(
|
const { result } = applyDiagramOperations(
|
||||||
originalXml,
|
originalXml,
|
||||||
completeOps.map((op) =>
|
completeOps.map((op) =>
|
||||||
@@ -235,6 +251,7 @@ export function useDiagramStreaming({
|
|||||||
}
|
}
|
||||||
: op,
|
: op,
|
||||||
),
|
),
|
||||||
|
selector,
|
||||||
)
|
)
|
||||||
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
if (lastProcessedXmlRef.current.get(resultKey) === result) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { toast } from "sonner"
|
|||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
import {
|
||||||
|
clearHighlights,
|
||||||
|
highlightChangedCells,
|
||||||
|
} from "@/lib/drawio/editor-bridge"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { useVersionsStore } from "@/stores/versions-store"
|
import { useVersionsStore } from "@/stores/versions-store"
|
||||||
@@ -46,29 +49,24 @@ export function useVersions({
|
|||||||
// (effect cleanups run before the streaming hook commits). The handler
|
// (effect cleanups run before the streaming hook commits). The handler
|
||||||
// only reads refs and stable functions.
|
// only reads refs and stable functions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCommitHandler(({ beforeXml, afterXml, toolCallId }) => {
|
setCommitHandler(({ beforeXml, afterXml, toolCallId, pageId }) => {
|
||||||
if (restoringRef.current) return
|
if (restoringRef.current) return
|
||||||
const { summary, touchedIds, fromScratch } = diffDiagrams(
|
const { summary, touchedIds, fromScratch } = diffDiagrams(
|
||||||
beforeXml,
|
beforeXml,
|
||||||
afterXml,
|
afterXml,
|
||||||
|
pageId,
|
||||||
)
|
)
|
||||||
const id = useVersionsStore.getState().addVersion({
|
const id = useVersionsStore.getState().addVersion({
|
||||||
xml: afterXml,
|
xml: afterXml,
|
||||||
beforeXml,
|
beforeXml,
|
||||||
|
...(pageId && { pageId }),
|
||||||
turnIndex: currentTurnRef.current,
|
turnIndex: currentTurnRef.current,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
summary,
|
summary,
|
||||||
fromScratch,
|
fromScratch,
|
||||||
})
|
})
|
||||||
// Mark what changed, unless the whole diagram is new
|
// Mark what changed, unless the whole diagram is new
|
||||||
if (!fromScratch) {
|
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||||
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
|
// 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
|
// newer version can be on it; once one is, this one gets none
|
||||||
// rather than the newer one's
|
// rather than the newer one's
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||||
|
|
||||||
// Dev XML presets for streaming simulator
|
// Dev XML presets for streaming simulator
|
||||||
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
|
|||||||
interface DevXmlSimulatorProps {
|
interface DevXmlSimulatorProps {
|
||||||
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
setMessages: React.Dispatch<React.SetStateAction<any[]>>
|
||||||
onDisplayChart: (xml: string) => void
|
onDisplayChart: (xml: string) => void
|
||||||
onShowQuotaToast?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevXmlSimulator({
|
export function DevXmlSimulator({
|
||||||
setMessages,
|
setMessages,
|
||||||
onDisplayChart,
|
onDisplayChart,
|
||||||
onShowQuotaToast,
|
|
||||||
}: DevXmlSimulatorProps) {
|
}: DevXmlSimulatorProps) {
|
||||||
const dict = useDictionary()
|
|
||||||
const [devXml, setDevXml] = useState("")
|
const [devXml, setDevXml] = useState("")
|
||||||
const [isSimulating, setIsSimulating] = useState(false)
|
const [isSimulating, setIsSimulating] = useState(false)
|
||||||
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
const [devIntervalMs, setDevIntervalMs] = useState(1)
|
||||||
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: dict.dev.simulatingMessage,
|
text: "[Dev] Simulating XML streaming",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
|
|||||||
const lastMsg = updated[updated.length - 1] as any
|
const lastMsg = updated[updated.length - 1] as any
|
||||||
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
|
||||||
lastMsg.parts[0].state = "output-available"
|
lastMsg.parts[0].state = "output-available"
|
||||||
lastMsg.parts[0].output = dict.dev.successMessage
|
lastMsg.parts[0].output = "Successfully displayed the diagram."
|
||||||
lastMsg.parts[0].input = { xml }
|
lastMsg.parts[0].input = { xml }
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
@@ -247,12 +243,12 @@ export function DevXmlSimulator({
|
|||||||
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
|
||||||
<details>
|
<details>
|
||||||
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
|
||||||
{dict.dev.title}
|
Dev: XML Streaming Simulator
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.preset}
|
Preset:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
|
|||||||
defaultValue=""
|
defaultValue=""
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{dict.dev.selectPreset}
|
Select a preset...
|
||||||
</option>
|
</option>
|
||||||
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
{Object.keys(DEV_XML_PRESETS).map((name) => (
|
||||||
<option key={name} value={name}>
|
<option key={name} value={name}>
|
||||||
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
|
|||||||
onClick={() => setDevXml("")}
|
onClick={() => setDevXml("")}
|
||||||
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
|
||||||
>
|
>
|
||||||
{dict.dev.clear}
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={devXml}
|
value={devXml}
|
||||||
onChange={(e) => setDevXml(e.target.value)}
|
onChange={(e) => setDevXml(e.target.value)}
|
||||||
placeholder={dict.dev.placeholder}
|
placeholder="Paste mxCell XML here or select a preset..."
|
||||||
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.interval}
|
Interval:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{dict.dev.chars}
|
Chars:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -332,8 +328,8 @@ export function DevXmlSimulator({
|
|||||||
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSimulating
|
{isSimulating
|
||||||
? dict.dev.streaming
|
? "Streaming..."
|
||||||
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
|
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
|
||||||
</button>
|
</button>
|
||||||
{isSimulating && (
|
{isSimulating && (
|
||||||
<button
|
<button
|
||||||
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
|
|||||||
}}
|
}}
|
||||||
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
>
|
>
|
||||||
{dict.dev.stop}
|
Stop
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onShowQuotaToast && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onShowQuotaToast}
|
|
||||||
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
|
|
||||||
>
|
|
||||||
{dict.dev.testQuotaToast}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
|
|
||||||
interface ErrorToastProps {
|
|
||||||
message: React.ReactNode
|
|
||||||
onDismiss: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
|
||||||
e.preventDefault()
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onDismiss}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-destructive"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// GitHub mark as an inline SVG, so no icon package is needed for one icon
|
||||||
|
export function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -164,7 +163,6 @@ export function ModelConfigDialog({
|
|||||||
)
|
)
|
||||||
const [customModelInput, setCustomModelInput] = useState("")
|
const [customModelInput, setCustomModelInput] = useState("")
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("")
|
|
||||||
// Models whose test is running (they are all tested at once)
|
// Models whose test is running (they are all tested at once)
|
||||||
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
@@ -441,7 +439,6 @@ export function ModelConfigDialog({
|
|||||||
setFetchModelsError("")
|
setFetchModelsError("")
|
||||||
updates = {
|
updates = {
|
||||||
...updates,
|
...updates,
|
||||||
validated: false,
|
|
||||||
models: selectedProvider.models.map((m) => ({
|
models: selectedProvider.models.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
validated: undefined,
|
validated: undefined,
|
||||||
@@ -659,10 +656,6 @@ export function ModelConfigDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A model whose id changed was not tested
|
|
||||||
if (allValid && !idChanged) {
|
|
||||||
updateProvider(selectedProviderId, { validated: true })
|
|
||||||
}
|
|
||||||
markRejected(selectedProviderId, askedWith, rejected)
|
markRejected(selectedProviderId, askedWith, rejected)
|
||||||
// The status line is about the provider shown now
|
// The status line is about the provider shown now
|
||||||
if (selectedProviderIdRef.current !== selectedProviderId) return
|
if (selectedProviderIdRef.current !== selectedProviderId) return
|
||||||
@@ -1004,7 +997,6 @@ export function ModelConfigDialog({
|
|||||||
name={provider.name}
|
name={provider.name}
|
||||||
baseUrl={provider.baseUrl}
|
baseUrl={provider.baseUrl}
|
||||||
awsRegion={provider.awsRegion}
|
awsRegion={provider.awsRegion}
|
||||||
bedrockApiKey
|
|
||||||
settingsLayout
|
settingsLayout
|
||||||
bedrockFilled={{
|
bedrockFilled={{
|
||||||
apiKey: !!provider.apiKey,
|
apiKey: !!provider.apiKey,
|
||||||
@@ -1389,10 +1381,7 @@ export function ModelConfigDialog({
|
|||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteConfirmOpen}
|
open={deleteConfirmOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={setDeleteConfirmOpen}
|
||||||
setDeleteConfirmOpen(open)
|
|
||||||
if (!open) setDeleteConfirmText("")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AlertDialogContent className="border-destructive/30">
|
<AlertDialogContent className="border-destructive/30">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -1410,39 +1399,11 @@ export function ModelConfigDialog({
|
|||||||
})}
|
})}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
{selectedProvider &&
|
|
||||||
selectedProvider.models.length >= 3 && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="delete-confirm"
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{formatMessage(t.typeToConfirm, {
|
|
||||||
name: providerName(selectedProvider),
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-confirm"
|
|
||||||
value={deleteConfirmText}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDeleteConfirmText(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={t.typeProviderName}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDeleteProvider}
|
onClick={handleDeleteProvider}
|
||||||
disabled={
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
selectedProvider &&
|
|
||||||
selectedProvider.models.length >= 3 &&
|
|
||||||
deleteConfirmText !==
|
|
||||||
providerName(selectedProvider)
|
|
||||||
}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{t.delete}
|
{t.delete}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
+368
-334
@@ -10,28 +10,38 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
ModelSelectorContent,
|
type ComponentProps,
|
||||||
ModelSelectorEmpty,
|
type ReactNode,
|
||||||
ModelSelectorGroup,
|
useEffect,
|
||||||
ModelSelectorInput,
|
useMemo,
|
||||||
ModelSelectorItem,
|
useRef,
|
||||||
ModelSelectorList,
|
useState,
|
||||||
ModelSelectorLogo,
|
} from "react"
|
||||||
ModelSelectorName,
|
|
||||||
ModelSelector as ModelSelectorRoot,
|
|
||||||
ModelSelectorSectionHeader,
|
|
||||||
ModelSelectorSeparator,
|
|
||||||
ModelSelectorTrigger,
|
|
||||||
} from "@/components/ai-elements/model-selector"
|
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
|
||||||
import { ProviderLogo } from "@/components/provider-logo"
|
import { ProviderLogo } from "@/components/provider-logo"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
type FlattenedModel,
|
Command,
|
||||||
PROVIDER_LOGO_MAP,
|
CommandEmpty,
|
||||||
} from "@/lib/types/model-config"
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
@@ -44,25 +54,22 @@ interface ModelSelectorProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
): Map<string, { provider: string; models: FlattenedModel[] }> {
|
): Map<string, ProviderGroup> {
|
||||||
const groups = new Map<
|
const groups = new Map<string, ProviderGroup>()
|
||||||
string,
|
|
||||||
{ provider: string; models: FlattenedModel[] }
|
|
||||||
>()
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
// For server models, strip "Server · " prefix for cleaner grouping
|
const existing = groups.get(model.providerLabel)
|
||||||
const key =
|
|
||||||
model.source === "server"
|
|
||||||
? model.providerLabel.replace(/^Server · /, "")
|
|
||||||
: model.providerLabel
|
|
||||||
const existing = groups.get(key)
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.models.push(model)
|
existing.models.push(model)
|
||||||
} else {
|
} else {
|
||||||
groups.set(key, { provider: model.provider, models: [model] })
|
groups.set(model.providerLabel, {
|
||||||
|
provider: model.provider,
|
||||||
|
models: [model],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
@@ -88,6 +95,72 @@ export function shortModelName(id: string): string {
|
|||||||
return short
|
return short
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The command list with a shadow at the bottom while more items are below
|
||||||
|
function ModelList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof CommandList>) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showShadow, setShowShadow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listElement = listRef.current
|
||||||
|
if (!listElement) return
|
||||||
|
|
||||||
|
const checkScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = listElement
|
||||||
|
// A small threshold handles fractional pixel rendering
|
||||||
|
setShowShadow(
|
||||||
|
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkScroll()
|
||||||
|
listElement.addEventListener("scroll", checkScroll)
|
||||||
|
window.addEventListener("resize", checkScroll)
|
||||||
|
// Content changes, e.g. the search filtering items
|
||||||
|
const observer = new MutationObserver(checkScroll)
|
||||||
|
observer.observe(listElement, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listElement.removeEventListener("scroll", checkScroll)
|
||||||
|
window.removeEventListener("resize", checkScroll)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
// Hide scrollbar on all platforms
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
|
||||||
|
showShadow ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1">
|
||||||
|
<span className="[&>svg]:size-3.5" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
models,
|
models,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
@@ -146,6 +219,52 @@ export function ModelSelector({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One group per provider; `trailing` is the small label at the end of a
|
||||||
|
// row ("Default" for server models, the test state for user models)
|
||||||
|
const renderGroups = (
|
||||||
|
groups: Map<string, ProviderGroup>,
|
||||||
|
keyPrefix: "server" | "user",
|
||||||
|
trailing: (model: FlattenedModel) => ReactNode,
|
||||||
|
) =>
|
||||||
|
Array.from(groups.entries()).map(
|
||||||
|
([providerLabel, { provider, models: providerModels }]) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={`${keyPrefix}-${providerLabel}`}
|
||||||
|
heading={providerLabel}
|
||||||
|
className="[&>[cmdk-group-heading]]:pl-4"
|
||||||
|
>
|
||||||
|
{providerModels.map((model) => (
|
||||||
|
<CommandItem
|
||||||
|
key={model.id}
|
||||||
|
// Unique value so same-named models highlight
|
||||||
|
// separately; keywords keep search by name
|
||||||
|
value={model.id}
|
||||||
|
keywords={[model.modelId, providerLabel]}
|
||||||
|
onSelect={() => handleSelect(model.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedModelId === model.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ProviderLogo
|
||||||
|
provider={provider}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{model.modelId}
|
||||||
|
</span>
|
||||||
|
{trailing(model)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const tooltipContent = selectedModel
|
const tooltipContent = selectedModel
|
||||||
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
|
||||||
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
|
||||||
@@ -186,329 +305,244 @@ export function ModelSelector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||||
<ModelSelectorRoot
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
if (next) toSettingsRef.current = false
|
if (next) toSettingsRef.current = false
|
||||||
setOpen(next)
|
setOpen(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelectorTrigger asChild>
|
{/* The dialog trigger must wrap the button itself: Radix
|
||||||
<ButtonWithTooltip
|
Tooltip's root renders no element to pass its props to */}
|
||||||
tooltipContent={tooltipContent}
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<DialogTrigger asChild>
|
||||||
disabled={disabled}
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"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",
|
size="sm"
|
||||||
!showLabel && "px-1.5 justify-center",
|
disabled={disabled}
|
||||||
)}
|
className={cn(
|
||||||
// accessibility: expose label to screen readers
|
"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",
|
||||||
aria-label={tooltipContent}
|
!showLabel && "px-1.5 justify-center",
|
||||||
data-testid="model-selector"
|
)}
|
||||||
>
|
// accessibility: expose label to screen readers
|
||||||
{selectedModel ? (
|
aria-label={tooltipContent}
|
||||||
<ProviderLogo
|
data-testid="model-selector"
|
||||||
provider={selectedModel.provider}
|
>
|
||||||
className="size-3.5 flex-shrink-0 opacity-80"
|
{selectedModel ? (
|
||||||
/>
|
<ProviderLogo
|
||||||
) : (
|
provider={selectedModel.provider}
|
||||||
<Bot className="h-4 w-4 flex-shrink-0" />
|
className="size-3.5 flex-shrink-0 opacity-80"
|
||||||
)}
|
/>
|
||||||
{/* show/hide visible label based on measured width */}
|
) : (
|
||||||
{showLabel ? (
|
<Bot className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="min-w-0 truncate text-xs">
|
)}
|
||||||
{selectedModel
|
{/* show/hide visible label based on measured width */}
|
||||||
? shortModelName(selectedModel.modelId)
|
{showLabel ? (
|
||||||
: dict.modelConfig.serverDefault}
|
<span className="min-w-0 truncate text-xs">
|
||||||
</span>
|
{selectedModel
|
||||||
) : (
|
? shortModelName(
|
||||||
// Keep an sr-only label for screen readers when hidden
|
selectedModel.modelId,
|
||||||
<span className="sr-only">
|
)
|
||||||
{selectedModel
|
: dict.modelConfig.serverDefault}
|
||||||
? selectedModel.modelId
|
</span>
|
||||||
: dict.modelConfig.serverDefault}
|
) : (
|
||||||
</span>
|
// Keep an sr-only label for screen readers when hidden
|
||||||
)}
|
<span className="sr-only">
|
||||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
{selectedModel
|
||||||
</ButtonWithTooltip>
|
? selectedModel.modelId
|
||||||
</ModelSelectorTrigger>
|
: dict.modelConfig.serverDefault}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs text-wrap">
|
||||||
|
{tooltipContent}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<ModelSelectorContent
|
<DialogContent
|
||||||
title={dict.modelConfig.selectModel}
|
className="p-0"
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
if (!toSettingsRef.current) return
|
if (!toSettingsRef.current) return
|
||||||
toSettingsRef.current = false
|
toSettingsRef.current = false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelectorInput
|
<DialogTitle className="sr-only">
|
||||||
placeholder={dict.modelConfig.searchModels}
|
{dict.modelConfig.selectModel}
|
||||||
/>
|
</DialogTitle>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<CommandInput
|
||||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
className="h-auto py-3.5"
|
||||||
<ModelSelectorEmpty>
|
placeholder={dict.modelConfig.searchModels}
|
||||||
{dict.modelConfig.noModelsFound}
|
/>
|
||||||
</ModelSelectorEmpty>
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<ModelList className="overflow-y-auto scrollbar-thin">
|
||||||
|
<CommandEmpty>
|
||||||
|
{dict.modelConfig.noModelsFound}
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
{/* Server Default Option - only show when no server models are configured */}
|
{/* Server Default Option - only show when no server models are configured */}
|
||||||
{serverModels.length === 0 && (
|
{serverModels.length === 0 && (
|
||||||
<ModelSelectorGroup
|
<CommandGroup
|
||||||
heading={dict.modelConfig.default}
|
heading={dict.modelConfig.default}
|
||||||
>
|
|
||||||
<ModelSelectorItem
|
|
||||||
value="__server_default__"
|
|
||||||
onSelect={handleSelect}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
!selectedModelId && "bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Check
|
<CommandItem
|
||||||
|
value="__server_default__"
|
||||||
|
onSelect={handleSelect}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"cursor-pointer",
|
||||||
!selectedModelId
|
!selectedModelId &&
|
||||||
? "opacity-100"
|
"bg-accent",
|
||||||
: "opacity-0",
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
!selectedModelId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.serverDefault
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server Models Section */}
|
||||||
|
{serverModels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Monitor />}
|
||||||
|
label={
|
||||||
|
dict.modelConfig
|
||||||
|
.serverModels
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
|
{renderGroups(
|
||||||
<ModelSelectorName>
|
groupedServerModels,
|
||||||
{dict.modelConfig.serverDefault}
|
"server",
|
||||||
</ModelSelectorName>
|
(model) =>
|
||||||
</ModelSelectorItem>
|
model.isDefault && (
|
||||||
</ModelSelectorGroup>
|
<span
|
||||||
)}
|
title={
|
||||||
|
dict.modelConfig
|
||||||
|
.serverDefaultModel
|
||||||
|
}
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.default
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Server Models Section */}
|
{/* User Models Section */}
|
||||||
{serverModels.length > 0 && (
|
{userModels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ModelSelectorSectionHeader
|
{serverModels.length > 0 && (
|
||||||
icon={<Monitor />}
|
<CommandSeparator />
|
||||||
label={
|
)}
|
||||||
dict.modelConfig.serverModels
|
<SectionHeader
|
||||||
}
|
icon={<User />}
|
||||||
/>
|
label={
|
||||||
{Array.from(
|
dict.modelConfig.userModels
|
||||||
groupedServerModels.entries(),
|
}
|
||||||
).map(
|
/>
|
||||||
([
|
{renderGroups(
|
||||||
providerLabel,
|
groupedUserModels,
|
||||||
{
|
"user",
|
||||||
provider,
|
(model) =>
|
||||||
models: providerModels,
|
model.validated !==
|
||||||
},
|
true && (
|
||||||
]) => (
|
<span
|
||||||
<ModelSelectorGroup
|
className={cn(
|
||||||
key={`server-${providerLabel}`}
|
"ml-auto shrink-0 pl-2 text-xs",
|
||||||
heading={providerLabel}
|
model.validated ===
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
false
|
||||||
>
|
? "text-destructive"
|
||||||
{providerModels.map(
|
: "text-muted-foreground",
|
||||||
(model) => (
|
)}
|
||||||
<ModelSelectorItem
|
>
|
||||||
key={model.id}
|
{model.validated ===
|
||||||
// Unique value so same-named models highlight
|
false
|
||||||
// separately; keywords keep search by name
|
? dict
|
||||||
value={model.id}
|
.modelConfig
|
||||||
keywords={[
|
.modelFailed
|
||||||
model.modelId,
|
: dict
|
||||||
providerLabel,
|
.modelConfig
|
||||||
]}
|
.modelUntested}
|
||||||
onSelect={() =>
|
</span>
|
||||||
handleSelect(
|
),
|
||||||
model.id,
|
)}
|
||||||
)
|
</>
|
||||||
}
|
)}
|
||||||
className="cursor-pointer"
|
</ModelList>
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.isDefault && (
|
|
||||||
<span
|
|
||||||
title={
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.serverDefaultModel
|
|
||||||
}
|
|
||||||
className="ml-auto text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dict
|
|
||||||
.modelConfig
|
|
||||||
.default
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Models Section */}
|
|
||||||
{userModels.length > 0 && (
|
|
||||||
<>
|
|
||||||
{serverModels.length > 0 && (
|
|
||||||
<ModelSelectorSeparator />
|
|
||||||
)}
|
|
||||||
<ModelSelectorSectionHeader
|
|
||||||
icon={<User />}
|
|
||||||
label={dict.modelConfig.userModels}
|
|
||||||
/>
|
|
||||||
{Array.from(
|
|
||||||
groupedUserModels.entries(),
|
|
||||||
).map(
|
|
||||||
([
|
|
||||||
providerLabel,
|
|
||||||
{
|
|
||||||
provider,
|
|
||||||
models: providerModels,
|
|
||||||
},
|
|
||||||
]) => (
|
|
||||||
<ModelSelectorGroup
|
|
||||||
key={`user-${providerLabel}`}
|
|
||||||
heading={providerLabel}
|
|
||||||
className="[&>[cmdk-group-heading]]:pl-4"
|
|
||||||
>
|
|
||||||
{providerModels.map(
|
|
||||||
(model) => (
|
|
||||||
<ModelSelectorItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
keywords={[
|
|
||||||
model.modelId,
|
|
||||||
providerLabel,
|
|
||||||
]}
|
|
||||||
onSelect={() =>
|
|
||||||
handleSelect(
|
|
||||||
model.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
selectedModelId ===
|
|
||||||
model.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<ModelSelectorLogo
|
|
||||||
provider={
|
|
||||||
PROVIDER_LOGO_MAP[
|
|
||||||
provider
|
|
||||||
] ||
|
|
||||||
provider
|
|
||||||
}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
<ModelSelectorName>
|
|
||||||
{
|
|
||||||
model.modelId
|
|
||||||
}
|
|
||||||
</ModelSelectorName>
|
|
||||||
{model.validated !==
|
|
||||||
true && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto shrink-0 pl-2 text-xs",
|
|
||||||
model.validated ===
|
|
||||||
false
|
|
||||||
? "text-destructive"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{model.validated ===
|
|
||||||
false
|
|
||||||
? dict
|
|
||||||
.modelConfig
|
|
||||||
.modelFailed
|
|
||||||
: dict
|
|
||||||
.modelConfig
|
|
||||||
.modelUntested}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ModelSelectorItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ModelSelectorGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ModelSelectorList>
|
|
||||||
</div>
|
|
||||||
{/* Pinned footer: add a provider, configure models
|
|
||||||
(z-10 above list shadow). Buttons, outside the
|
|
||||||
search: reachable with Tab, never filtered out */}
|
|
||||||
{(onAddProvider || onConfigure) && (
|
|
||||||
<div
|
|
||||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
|
||||||
// The search's Enter would pick the highlighted
|
|
||||||
// model instead of pressing the button
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{onAddProvider && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
goToSettings(onAddProvider)
|
|
||||||
}
|
|
||||||
className={footerButton}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">
|
|
||||||
{dict.modelConfig.addProviderEntry}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onConfigure && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
goToSettings(onConfigure)
|
|
||||||
}
|
|
||||||
className={footerButton}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">
|
|
||||||
{dict.modelConfig.configureModels}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Pinned footer: add a provider, configure models
|
||||||
</div>
|
(z-10 above list shadow). Buttons, outside the
|
||||||
</ModelSelectorContent>
|
search: reachable with Tab, never filtered out */}
|
||||||
</ModelSelectorRoot>
|
{(onAddProvider || onConfigure) && (
|
||||||
|
<div
|
||||||
|
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||||
|
// The search's Enter would pick the highlighted
|
||||||
|
// model instead of pressing the button
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{onAddProvider && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
goToSettings(onAddProvider)
|
||||||
|
}
|
||||||
|
className={footerButton}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.addProviderEntry
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onConfigure && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
goToSettings(onConfigure)
|
||||||
|
}
|
||||||
|
className={footerButton}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{
|
||||||
|
dict.modelConfig
|
||||||
|
.configureModels
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ interface ProviderCredentialsFieldsProps {
|
|||||||
name?: string
|
name?: string
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
awsRegion?: string
|
awsRegion?: string
|
||||||
// Bedrock: also offer an API key (the user dialog; the admin panel
|
|
||||||
// stores access keys only)
|
|
||||||
bedrockApiKey?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
// Update a plain text field
|
// Update a plain text field
|
||||||
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
|
||||||
@@ -68,9 +65,6 @@ interface ProviderCredentialsFieldsProps {
|
|||||||
// UI (e.g. the user dialog's inline Test button + validation error); the
|
// UI (e.g. the user dialog's inline Test button + validation error); the
|
||||||
// shared component only supplies the label above it.
|
// shared component only supplies the label above it.
|
||||||
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
|
||||||
// Extra content after the fields — used for the Bedrock test row and the
|
|
||||||
// EdgeOne test button, which aren't beside a credential input.
|
|
||||||
footer?: ReactNode
|
|
||||||
// The user's settings page: the secrets come first, the display name and
|
// The user's settings page: the secrets come first, the display name and
|
||||||
// base URL fold under "More options", and Bedrock shows only the way of
|
// base URL fold under "More options", and Bedrock shows only the way of
|
||||||
// signing in that is chosen. Switching it empties the other way's
|
// signing in that is chosen. Switching it empties the other way's
|
||||||
@@ -91,11 +85,9 @@ export function ProviderCredentialsFields({
|
|||||||
name,
|
name,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
awsRegion,
|
awsRegion,
|
||||||
bedrockApiKey,
|
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
renderSecret,
|
renderSecret,
|
||||||
footer,
|
|
||||||
settingsLayout,
|
settingsLayout,
|
||||||
clearSecrets,
|
clearSecrets,
|
||||||
bedrockFilled,
|
bedrockFilled,
|
||||||
@@ -269,10 +261,8 @@ export function ProviderCredentialsFields({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
// EdgeOne needs no credentials
|
||||||
if (provider === "edgeone") {
|
if (provider === "edgeone") return null
|
||||||
return <div className="space-y-5">{footer}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsLayout) {
|
if (settingsLayout) {
|
||||||
return (
|
return (
|
||||||
@@ -447,7 +437,6 @@ export function ProviderCredentialsFields({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -458,26 +447,6 @@ export function ProviderCredentialsFields({
|
|||||||
|
|
||||||
{provider === "bedrock" ? (
|
{provider === "bedrock" ? (
|
||||||
<>
|
<>
|
||||||
{/* Bedrock API key: used instead of the access keys */}
|
|
||||||
{bedrockApiKey && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="bedrock-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.bedrockApiKey}
|
|
||||||
</Label>
|
|
||||||
{renderSecret({
|
|
||||||
field: "apiKey",
|
|
||||||
id: "bedrock-api-key",
|
|
||||||
})}
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.modelConfig.bedrockApiKeyHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AWS Access Key ID */}
|
{/* AWS Access Key ID */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
@@ -535,8 +504,6 @@ export function ProviderCredentialsFields({
|
|||||||
{baseUrlField}
|
{baseUrlField}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{footer}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Coffee, Settings, X } from "lucide-react"
|
import { Coffee, Settings, X } from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { FaGithub } from "react-icons/fa"
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
|
||||||
@@ -28,16 +28,10 @@ export function QuotaLimitToast({
|
|||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||||
|
|
||||||
const quotaMessage = isTokenLimit
|
const quotaMessage = isTokenLimit
|
||||||
? isSelfHosted
|
? dict.quota.messageToken
|
||||||
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
: dict.quota.messageApi
|
||||||
: dict.quota.messageToken
|
|
||||||
: isSelfHosted
|
|
||||||
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
|
||||||
: dict.quota.messageApi
|
|
||||||
|
|
||||||
const tipHtml = isSelfHosted
|
const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
|
||||||
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
|
||||||
: dict.quota.tip
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -52,7 +46,7 @@ export function QuotaLimitToast({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
|
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-float animate-message-in"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
@@ -115,7 +109,7 @@ export function QuotaLimitToast({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
<FaGithub className="w-3.5 h-3.5" />
|
<GitHubIcon className="w-3.5 h-3.5" />
|
||||||
{dict.quota.selfHost}
|
{dict.quota.selfHost}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Github,
|
|
||||||
Info,
|
Info,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -13,6 +12,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||||
|
import { GitHubIcon } from "@/components/icons/github"
|
||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -510,7 +510,7 @@ function AboutTab() {
|
|||||||
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
|
||||||
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
|
||||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
const aboutPath = `/${lang}/about`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -553,7 +553,7 @@ function AboutTab() {
|
|||||||
rel="noopener noreferrer"
|
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"
|
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" />
|
<GitHubIcon className="size-3.5" />
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
{showAbout && (
|
{showAbout && (
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ function AlertDialog({
|
|||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
@@ -144,9 +136,6 @@ function AlertDialogCancel({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
|
|||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
function Command({
|
function Command({
|
||||||
className,
|
className,
|
||||||
@@ -29,32 +22,6 @@ function Command({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
|
||||||
title = "Command Palette",
|
|
||||||
description = "Search for a command to run...",
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandInput({
|
function CommandInput({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -164,30 +131,12 @@ function CommandItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="command-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ function DialogPortal({
|
|||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -65,7 +59,7 @@ function DialogContent({
|
|||||||
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
|
||||||
"grid gap-4 p-6",
|
"grid gap-4 p-6",
|
||||||
// Refined visual treatment
|
// Refined visual treatment
|
||||||
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
|
"bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
|
||||||
// Entry/exit animations
|
// Entry/exit animations
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
@@ -151,13 +145,10 @@ function DialogDescription({
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,4 @@ function PopoverContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverAnchor({
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
|
||||||
|
|||||||
@@ -55,4 +55,4 @@ function ScrollBar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
export { ScrollArea }
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ function Select({
|
|||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
@@ -87,19 +81,6 @@ function SelectContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -124,19 +105,6 @@ function SelectItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
|
|||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { getAssetUrl } from "@/lib/base-path"
|
import { getAssetUrl } from "@/lib/base-path"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/** The app icon in a rounded tile; the white variant is used in dark mode */
|
/** The app icon in a rounded tile; the icon has its own white tile, so dark mode uses it too */
|
||||||
export function BrandMark({ className }: { className?: string }) {
|
export function BrandMark({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -16,13 +16,7 @@ export function BrandMark({ className }: { className?: string }) {
|
|||||||
<img
|
<img
|
||||||
src={getAssetUrl("/favicon-192x192.png")}
|
src={getAssetUrl("/favicon-192x192.png")}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-full object-contain dark:hidden"
|
className="size-full object-contain"
|
||||||
/>
|
|
||||||
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
|
|
||||||
<img
|
|
||||||
src={getAssetUrl("/favicon-white.svg")}
|
|
||||||
alt=""
|
|
||||||
className="hidden size-5 object-contain dark:block"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ interface IconButtonProps extends React.ComponentProps<"button"> {
|
|||||||
label: string
|
label: string
|
||||||
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
/** Shown next to the label in the tooltip, e.g. "⌘B" */
|
||||||
shortcut?: string
|
shortcut?: string
|
||||||
active?: boolean
|
|
||||||
tooltipSide?: "top" | "bottom" | "left" | "right"
|
tooltipSide?: "top" | "bottom" | "left" | "right"
|
||||||
size?: "sm" | "md"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Square icon button with a tooltip; the tooltip text is also its label */
|
/** Square icon button with a tooltip; the tooltip text is also its label */
|
||||||
@@ -24,9 +22,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
{
|
{
|
||||||
label,
|
label,
|
||||||
shortcut,
|
shortcut,
|
||||||
active = false,
|
|
||||||
tooltipSide = "bottom",
|
tooltipSide = "bottom",
|
||||||
size = "md",
|
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
@@ -40,17 +36,12 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active || undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
|
||||||
"hover:bg-accent hover:text-foreground",
|
"hover:bg-accent hover:text-foreground",
|
||||||
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
|
||||||
"disabled:pointer-events-none disabled:opacity-35",
|
"disabled:pointer-events-none disabled:opacity-35",
|
||||||
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
|
"[&_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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,8 +64,3 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Thin vertical divider between toolbar groups */
|
|
||||||
export function ToolbarDivider() {
|
|
||||||
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
|
|||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||||
|
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||||
|
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||||
import {
|
import {
|
||||||
ChatEngineProvider,
|
ChatEngineProvider,
|
||||||
useChatEngine,
|
useChatEngine,
|
||||||
} from "@/components/chat/chat-engine"
|
} from "@/components/chat/chat-engine"
|
||||||
import { ChatPanel } from "@/components/chat/chat-panel"
|
import { ChatPanel } from "@/components/chat/chat-panel"
|
||||||
import { CompareDialog } from "@/components/chat/compare-dialog"
|
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
|
||||||
import { LobbyHero } from "@/components/chat/lobby-hero"
|
import { LobbyHero } from "@/components/chat/lobby-hero"
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
@@ -116,7 +118,9 @@ export function Workspace() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatEngineProvider>
|
<ChatEngineProvider>
|
||||||
<WorkspaceLayout />
|
<ChatVersionsProvider>
|
||||||
|
<WorkspaceLayout />
|
||||||
|
</ChatVersionsProvider>
|
||||||
</ChatEngineProvider>
|
</ChatEngineProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -146,7 +150,6 @@ function WorkspaceLayout() {
|
|||||||
const showHero =
|
const showHero =
|
||||||
engine.isRestored &&
|
engine.isRestored &&
|
||||||
engine.messages.length === 0 &&
|
engine.messages.length === 0 &&
|
||||||
!engine.isResending &&
|
|
||||||
!isRealDiagram(chartXML) &&
|
!isRealDiagram(chartXML) &&
|
||||||
!heroDismissed
|
!heroDismissed
|
||||||
|
|
||||||
@@ -267,6 +270,13 @@ function WorkspaceLayout() {
|
|||||||
}
|
}
|
||||||
style={isMobile ? undefined : { right: canvasRight }}
|
style={isMobile ? undefined : { right: canvasRight }}
|
||||||
hideOverlays={!isMobile && showHero}
|
hideOverlays={!isMobile && showHero}
|
||||||
|
overlay={(size) => (
|
||||||
|
<SelectionAsk
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
waitForSavedLocale
|
||||||
/>
|
/>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
mobileView === "chat" &&
|
mobileView === "chat" &&
|
||||||
|
|||||||
+214
-143
@@ -23,12 +23,14 @@ import {
|
|||||||
previewDiagram,
|
previewDiagram,
|
||||||
resetPreview,
|
resetPreview,
|
||||||
revertPreview,
|
revertPreview,
|
||||||
|
selectPage,
|
||||||
} from "@/lib/drawio/editor-bridge"
|
} from "@/lib/drawio/editor-bridge"
|
||||||
import {
|
import {
|
||||||
BLANK_MXFILE,
|
BLANK_MXFILE,
|
||||||
normalizeToMxfile,
|
normalizeToMxfile,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
|
import { useCanvasStore } from "@/stores/canvas-store"
|
||||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,26 +56,38 @@ export interface DiagramCommit {
|
|||||||
beforeXml: string
|
beforeXml: string
|
||||||
afterXml: string
|
afterXml: string
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
|
/** The page the change was made on; null or absent: the first page */
|
||||||
|
pageId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An export as draw.io's export action takes it: the format, and for PNG
|
||||||
|
* its scale, width cap and the page to render (pageId) */
|
||||||
|
export interface ExportRequest {
|
||||||
|
format: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What draw.io answered: the image (a data URL, or SVG text) in data; for
|
||||||
|
* format "xml" the document in xml instead */
|
||||||
|
export interface ExportResult {
|
||||||
|
data?: string
|
||||||
|
xml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||||
chartXMLRef: React.MutableRefObject<string>
|
chartXMLRef: React.MutableRefObject<string>
|
||||||
latestSvg: string
|
// The diagram's latest SVG export, a fallback thumbnail for saves
|
||||||
|
latestSvgRef: React.MutableRefObject<string>
|
||||||
loadDiagram: (
|
loadDiagram: (
|
||||||
chart: string,
|
chart: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode?: LoadMode,
|
mode?: LoadMode,
|
||||||
meta?: { toolCallId?: string },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
) => string | null
|
) => string | null
|
||||||
// Returns the export's tag (empty when draw.io is not there yet)
|
/** The XML of the page on screen, exported from draw.io */
|
||||||
handleExport: () => string
|
fetchDiagramXml: () => Promise<string>
|
||||||
// Pending exports by tag; a plain export's resolver gets the first
|
|
||||||
// page's XML
|
|
||||||
exportResolversRef: React.MutableRefObject<
|
|
||||||
Record<string, (data: string, xml?: string) => void>
|
|
||||||
>
|
|
||||||
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
|
||||||
handleDiagramExport: (data: DrawioExportEvent) => void
|
handleDiagramExport: (data: DrawioExportEvent) => void
|
||||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
@@ -83,10 +97,23 @@ interface DiagramContextType {
|
|||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
successMessage?: string,
|
successMessage?: string,
|
||||||
) => void
|
) => Promise<void>
|
||||||
getThumbnailSvg: () => Promise<string | null>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
getVersionSvg: () => Promise<string | null>
|
getVersionSvg: () => Promise<string | null>
|
||||||
captureValidationPng: () => Promise<string | null>
|
captureValidationPng: () => Promise<string | null>
|
||||||
|
/** One export with its own result; null when draw.io does not answer
|
||||||
|
* in time (the MCP shell answers the server's export requests with it) */
|
||||||
|
requestExport: (
|
||||||
|
request: ExportRequest,
|
||||||
|
timeoutMs?: number,
|
||||||
|
) => Promise<ExportResult | null>
|
||||||
|
/** Show a document for an export only (the MCP's one-page projection of
|
||||||
|
* another page): not recorded as the diagram, its autosaves ignored,
|
||||||
|
* until the next loadDiagram puts the real document back */
|
||||||
|
showTransient: (xml: string) => void
|
||||||
|
/** A full load was sent and draw.io has not reported it yet: the
|
||||||
|
* editor still shows the previous document */
|
||||||
|
hasPendingLoad: () => boolean
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -104,11 +131,11 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
|||||||
// echoes the request back in the export event, so each result reaches its
|
// 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
|
// own caller. Tags end in a request number, so a late result never answers
|
||||||
// a newer request.
|
// a newer request.
|
||||||
type ExportTag = "thumbnail" | "validation" | "version"
|
type ExportTag = "thumbnail" | "validation" | "version" | "fetch" | "save"
|
||||||
|
|
||||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [chartXML, setChartXML] = useState<string>("")
|
const [chartXML, setChartXML] = useState<string>("")
|
||||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
const latestSvgRef = useRef("")
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
@@ -121,7 +148,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
const drawioRef = useRef<DrawioFrameHandle | null>(null)
|
||||||
// Pending exports, keyed by their export tag
|
// Pending exports, keyed by their export tag
|
||||||
const exportResolversRef = useRef<
|
const exportResolversRef = useRef<
|
||||||
Record<string, (data: string, xml?: string) => void>
|
Record<string, (result: ExportResult) => void>
|
||||||
>({})
|
>({})
|
||||||
const exportSeqRef = useRef(0)
|
const exportSeqRef = useRef(0)
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
@@ -134,17 +161,34 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The page to show again once a full load is done: draw.io shows the
|
||||||
|
// first page of a loaded document
|
||||||
|
const pageAfterLoadRef = useRef<string | null>(null)
|
||||||
|
// A transient document (showTransient) is on the canvas, and the page
|
||||||
|
// the user was on before it
|
||||||
|
const transientRef = useRef(false)
|
||||||
|
const pageBeforeTransientRef = useRef<string | null>(null)
|
||||||
|
|
||||||
// Sends a full load. draw.io runs it when its message arrives, and then
|
// Sends a full load. draw.io runs it when its message arrives, and then
|
||||||
// reports "load": until then the editor shows the diagram from before,
|
// reports "load": until then the editor shows the diagram from before,
|
||||||
// so later changes go the same way and keep their order
|
// so later changes go the same way and keep their order
|
||||||
const fullLoad = (xml: string) => {
|
const fullLoad = (
|
||||||
|
xml: string,
|
||||||
|
pageId: string | null = useCanvasStore.getState().currentPageId,
|
||||||
|
) => {
|
||||||
if (!drawioRef.current) return
|
if (!drawioRef.current) return
|
||||||
|
pageAfterLoadRef.current =
|
||||||
|
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
|
||||||
pendingLoadsRef.current++
|
pendingLoadsRef.current++
|
||||||
drawioRef.current.load({ xml })
|
drawioRef.current.load({ xml })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDrawioLoad = () => {
|
const onDrawioLoad = () => {
|
||||||
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
|
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
|
||||||
|
// Back to the page the user was on
|
||||||
|
const pageId = pageAfterLoadRef.current
|
||||||
|
pageAfterLoadRef.current = null
|
||||||
|
if (pageId) selectPage(pageId)
|
||||||
// Only set ready state once to prevent infinite loops
|
// Only set ready state once to prevent infinite loops
|
||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
@@ -169,26 +213,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setChartXML(xml)
|
setChartXML(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
if (!drawioRef.current) return ""
|
|
||||||
// Export of the current state (for the chat and edit_diagram)
|
|
||||||
const tag = `fetch-${++exportSeqRef.current}`
|
|
||||||
drawioRef.current.exportDiagram({
|
|
||||||
format: "xmlsvg",
|
|
||||||
message: tag,
|
|
||||||
})
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
// 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
|
// event) and wait for that result. Resolves to null on timeout, which is
|
||||||
// expected occasionally.
|
// expected occasionally.
|
||||||
// (Reads refs only, so it keeps one identity)
|
// (Reads refs only, so it keeps one identity)
|
||||||
const requestTaggedExport = useCallback(
|
const requestTaggedExport = useCallback(
|
||||||
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
(tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
|
||||||
new Promise<string | null>((resolve) => {
|
new Promise<ExportResult | null>((resolve) => {
|
||||||
const id = `${tag}-${++exportSeqRef.current}`
|
const id = `${tag}-${++exportSeqRef.current}`
|
||||||
const finish = (value: string | null) => {
|
const finish = (value: ExportResult | null) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
delete exportResolversRef.current[id]
|
delete exportResolversRef.current[id]
|
||||||
resolve(value)
|
resolve(value)
|
||||||
@@ -201,20 +234,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get current diagram as SVG for thumbnail (used by session storage).
|
// Get current diagram as SVG for thumbnail (used by session storage).
|
||||||
// One identity: the chat's auto-save depends on it, and each thumbnail
|
// One identity: the chat's auto-save effect depends on it
|
||||||
// renders this provider again (latestSvg), which would otherwise start
|
|
||||||
// the next save
|
|
||||||
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
// xmlsvg exports return an SVG data URL
|
// xmlsvg exports return an SVG data URL
|
||||||
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
|
||||||
if (svgData?.startsWith("data:image/svg")) {
|
if (result?.data?.startsWith("data:image/svg")) {
|
||||||
// Update latestSvg so it's available for future saves
|
// Kept for later saves that take no fresh thumbnail
|
||||||
setLatestSvg(svgData)
|
latestSvgRef.current = result.data
|
||||||
return svgData
|
return result.data
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}, [requestTaggedExport])
|
}, [requestTaggedExport])
|
||||||
@@ -222,8 +253,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Plain SVG (no embedded diagram data) for version cards
|
// Plain SVG (no embedded diagram data) for version cards
|
||||||
const getVersionSvg = async (): Promise<string | null> => {
|
const getVersionSvg = async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return null
|
if (!drawioRef.current) return null
|
||||||
const svgData = await requestTaggedExport("version", "svg", 4000)
|
const result = await requestTaggedExport("version", "svg", 4000)
|
||||||
return svgData?.startsWith("data:image/svg") ? svgData : null
|
return result?.data?.startsWith("data:image/svg") ? result.data : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current diagram as PNG for VLM validation
|
// Capture current diagram as PNG for VLM validation
|
||||||
@@ -232,20 +263,75 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Don't export if diagram is empty
|
// Don't export if diagram is empty
|
||||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||||
|
|
||||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
const result = await requestTaggedExport("validation", "png", 5000)
|
||||||
// PNG data should be a base64 data URL
|
// PNG data should be a base64 data URL
|
||||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
return result?.data?.startsWith("data:image/png") ? result.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page the user is viewing (the first one with an external draw.io,
|
||||||
|
// which cannot tell us), for the chat and edit_diagram.
|
||||||
|
// Not written to chartXML: exports don't change the diagram, and the
|
||||||
|
// export's xml 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 fetchDiagramXml = async (): Promise<string> => {
|
||||||
|
const result = await requestTaggedExport("fetch", "xmlsvg", 10000)
|
||||||
|
if (!result?.data) {
|
||||||
|
throw new Error("Chart export timed out after 10 seconds")
|
||||||
|
}
|
||||||
|
latestSvgRef.current = result.data
|
||||||
|
return extractDiagramXML(
|
||||||
|
result.data,
|
||||||
|
useCanvasStore.getState().currentPageId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
||||||
|
// page and width) and both parts of the answer
|
||||||
|
const requestExport = useCallback(
|
||||||
|
(request: ExportRequest, timeoutMs = 10000) =>
|
||||||
|
new Promise<ExportResult | null>((resolve) => {
|
||||||
|
if (!drawioRef.current) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = `export-${++exportSeqRef.current}`
|
||||||
|
const finish = (value: ExportResult | null) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
delete exportResolversRef.current[id]
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||||
|
exportResolversRef.current[id] = finish
|
||||||
|
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showTransient = (xml: string) => {
|
||||||
|
if (!transientRef.current) {
|
||||||
|
pageBeforeTransientRef.current =
|
||||||
|
useCanvasStore.getState().currentPageId
|
||||||
|
}
|
||||||
|
transientRef.current = true
|
||||||
|
fullLoad(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDiagram = (
|
const loadDiagram = (
|
||||||
chart: string,
|
chart: string,
|
||||||
skipValidation?: boolean,
|
skipValidation?: boolean,
|
||||||
mode: LoadMode = "load",
|
mode: LoadMode = "load",
|
||||||
meta?: { toolCallId?: string },
|
meta?: { toolCallId?: string; pageId?: string | null },
|
||||||
): string | null => {
|
): string | null => {
|
||||||
// The editor bridge is shared: a page that is gone (another language
|
// The editor bridge is shared: a page that is gone (another language
|
||||||
// mounted a new one) must not change the new page's canvas
|
// mounted a new one) must not change the new page's canvas
|
||||||
if (!mountedRef.current) return null
|
if (!mountedRef.current) return null
|
||||||
|
// A real document ends a transient one; the user goes back to the
|
||||||
|
// page they were on before it
|
||||||
|
const pageBeforeTransient = transientRef.current
|
||||||
|
? pageBeforeTransientRef.current
|
||||||
|
: undefined
|
||||||
|
transientRef.current = false
|
||||||
let xmlToLoad = chart
|
let xmlToLoad = chart
|
||||||
|
|
||||||
// Validate XML structure before loading (unless skipped for internal
|
// Validate XML structure before loading (unless skipped for internal
|
||||||
@@ -300,7 +386,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!applied) {
|
if (!applied) {
|
||||||
// A full load replaces any preview, and its base is stale now
|
// A full load replaces any preview, and its base is stale now
|
||||||
resetPreview()
|
resetPreview()
|
||||||
fullLoad(xmlToLoad)
|
fullLoad(xmlToLoad, pageBeforeTransient)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "commit") {
|
if (mode === "commit") {
|
||||||
@@ -308,6 +394,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
beforeXml,
|
beforeXml,
|
||||||
afterXml: xmlToLoad,
|
afterXml: xmlToLoad,
|
||||||
toolCallId: meta?.toolCallId,
|
toolCallId: meta?.toolCallId,
|
||||||
|
pageId: meta?.pageId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,35 +406,22 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resetPreview()
|
resetPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each export result goes to the caller that asked for it, by its tag
|
||||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||||
// Thumbnail, version, validation PNG and file save exports go only
|
|
||||||
// to their own caller
|
|
||||||
const tag = data.message?.message
|
const tag = data.message?.message
|
||||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
if (!tag) return
|
||||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
|
||||||
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)
|
|
||||||
setLatestSvg(data.data)
|
|
||||||
|
|
||||||
// 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
|
// 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
|
// read refs: state captured in its closure would stay stale after a remount
|
||||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
if (!data?.xml) return
|
if (!data?.xml) return
|
||||||
|
// An edit of a transient document is not the diagram's; nor is one
|
||||||
|
// of the canvas a full load still on its way replaces (draw.io
|
||||||
|
// reports each load, in order, so no autosave of the new document
|
||||||
|
// comes before that)
|
||||||
|
if (transientRef.current || pendingLoadsRef.current > 0) return
|
||||||
// Don't overwrite a pending restore - if we have a diagram but
|
// Don't overwrite a pending restore - if we have a diagram but
|
||||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||||
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
||||||
@@ -362,10 +436,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const emptyDiagram = BLANK_MXFILE
|
const emptyDiagram = BLANK_MXFILE
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
loadDiagram(emptyDiagram, true)
|
loadDiagram(emptyDiagram, true)
|
||||||
setLatestSvg("")
|
latestSvgRef.current = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDiagramToFile = (
|
const saveDiagramToFile = async (
|
||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
@@ -380,85 +454,80 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const drawioFormat =
|
const drawioFormat =
|
||||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Each save has its own tag, so two at once never swap results
|
// Each save has its own tag, so two at once never swap results. A
|
||||||
const tag = `save-${++exportSeqRef.current}`
|
// large PNG takes a while to render.
|
||||||
exportResolversRef.current[tag] = (
|
const result = await requestTaggedExport("save", drawioFormat, 30000)
|
||||||
exportData: string,
|
if (!result?.data) {
|
||||||
fullDiagramXML?: string,
|
console.warn("Diagram export for saving timed out")
|
||||||
) => {
|
return
|
||||||
delete exportResolversRef.current[tag]
|
}
|
||||||
let fileContent: string | Blob
|
const exportData = result.data
|
||||||
let mimeType: string
|
const fullDiagramXML = result.xml
|
||||||
let extension: string
|
let fileContent: string | Blob
|
||||||
|
let mimeType: string
|
||||||
|
let extension: string
|
||||||
|
|
||||||
if (format === "drawio") {
|
if (format === "drawio") {
|
||||||
// Prefer the complete document from the export event so all pages are saved.
|
// Prefer the complete document from the export event so all pages are saved.
|
||||||
const xml = fullDiagramXML?.trim()
|
const xml = fullDiagramXML?.trim()
|
||||||
? fullDiagramXML
|
? fullDiagramXML
|
||||||
: extractDiagramXML(exportData)
|
: extractDiagramXML(exportData)
|
||||||
fileContent =
|
fileContent =
|
||||||
normalizeToMxfile(xml, {
|
normalizeToMxfile(xml, {
|
||||||
pageId: "page-1",
|
pageId: "page-1",
|
||||||
pageName: "Page-1",
|
pageName: "Page-1",
|
||||||
}) ?? xml
|
}) ?? xml
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
} else if (format === "xmlsvg") {
|
} else if (format === "xmlsvg") {
|
||||||
// Editable SVG: pass data URL directly (like PNG)
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".drawio.svg"
|
extension = ".drawio.svg"
|
||||||
} else {
|
} else {
|
||||||
// SVG format (view-only)
|
// SVG format (view-only)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, { duration: 2500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
|
||||||
if (!url.startsWith("data:")) {
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export diagram - callback will be handled in handleDiagramExport
|
// Log save event to Langfuse (flags the trace)
|
||||||
drawioRef.current.exportDiagram({
|
logSaveToLangfuse(filename, format, sessionId)
|
||||||
format: drawioFormat,
|
|
||||||
message: tag,
|
// 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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||||
@@ -489,10 +558,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
chartXML,
|
chartXML,
|
||||||
chartXMLRef,
|
chartXMLRef,
|
||||||
latestSvg,
|
latestSvgRef,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
fetchDiagramXml,
|
||||||
exportResolversRef,
|
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
handleDiagramAutoSave,
|
handleDiagramAutoSave,
|
||||||
@@ -501,6 +569,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
getVersionSvg,
|
getVersionSvg,
|
||||||
captureValidationPng,
|
captureValidationPng,
|
||||||
|
requestExport,
|
||||||
|
showTransient,
|
||||||
|
hasPendingLoad: () => pendingLoadsRef.current > 0,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
||||||
|
|
||||||
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
||||||
|
- 预览页就是网页版的画布:AI 的改动会被标出,按一次 Ctrl+Z 就能撤销,AI 还能读到你选中的图形
|
||||||
|
- draw.io 编辑器随包一起安装,装好后预览不需要联网(也可以用 `DRAWIO_BASE_URL` 改用外部的 draw.io)
|
||||||
- 截图工具,AI 可以看一眼画好的图并自行修正
|
- 截图工具,AI 可以看一眼画好的图并自行修正
|
||||||
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
||||||
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
||||||
|
- 可以让 AI 撤销修改,也可以在 `~/.next-ai-drawio/instructions.md` 里写下自己的画图规则
|
||||||
|
|
||||||
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
|
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
|
||||||
| `models` | 是 | 模型 ID 列表 |
|
| `models` | 是 | 模型 ID 列表 |
|
||||||
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
|
||||||
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
|
||||||
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
|
||||||
|
|
||||||
### 说明
|
### 说明
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## 第三步 — 选择部署类型
|
## 第三步 — 选择部署类型
|
||||||
|
|
||||||
### 选项 A:不使用 R2 部署(简单,免费)
|
### 选项 A:不使用 R2 部署(默认,免费)
|
||||||
|
|
||||||
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
|
仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
|
||||||
|
|
||||||
**1. 使用简单的 `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
直接跳至 **第四步**。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 选项 B:使用 R2 部署(完整的 ISR 支持)
|
### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
|
||||||
|
|
||||||
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
|
R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
|
||||||
|
|
||||||
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. 配置 `wrangler.jsonc`(包含 R2):**
|
**3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
|
||||||
|
|
||||||
|
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API 路由 | 支持 | 支持 |
|
| API 路由 | 支持 | 支持 |
|
||||||
| 配置复杂度 | 简单 | 中等 |
|
| 配置复杂度 | 简单 | 中等 |
|
||||||
|
|
||||||
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
|
仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)
|
|||||||
|
|
||||||
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
构建会从 [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`。构建时就会使用这份副本。
|
- 在另一台机器上下载 `packages/mcp-server/src/drawio-version.json` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||||
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
||||||
|
|
||||||
## 来自其他网站的图片
|
## 来自其他网站的图片
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
|
|||||||
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
|
||||||
| `models` | Yes | List of model IDs |
|
| `models` | Yes | List of model IDs |
|
||||||
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
| `default` | No | Set to `true` to auto-select this provider's first model as default |
|
||||||
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
|
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
|
||||||
| `baseUrlEnv` | No | Custom base URL env var name |
|
| `baseUrlEnv` | No | Custom base URL env var name |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ Fill in the same values there.
|
|||||||
|
|
||||||
## Step 3 — Choose your deployment type
|
## Step 3 — Choose your deployment type
|
||||||
|
|
||||||
### Option A: Deploy WITHOUT R2 (Simple, Free)
|
### Option A: Deploy WITHOUT R2 (Default, Free)
|
||||||
|
|
||||||
If you don't need ISR caching, you can deploy without R2:
|
This is how the repository is configured: `open-next.config.ts` is `defineCloudflareConfig({})` and `wrangler.jsonc` has no `r2_buckets`. Nothing to change here. Skip to **Step 4**.
|
||||||
|
|
||||||
**1. Use simple `open-next.config.ts`:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Skip to **Step 4**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option B: Deploy WITH R2 (Full ISR Support)
|
### Option B: Deploy WITH R2 (Optional, ISR caching)
|
||||||
|
|
||||||
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
|
R2 gives OpenNext a place to store **Incremental Static Regeneration (ISR)** pages. This app currently has no ISR pages, so you only need this step if you add some. Requires a payment method on your Cloudflare account.
|
||||||
|
|
||||||
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
**1. Create an R2 bucket** in the Cloudflare Dashboard:
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Configure `wrangler.jsonc` (with R2):**
|
**3. Add `r2_buckets` to `wrangler.jsonc`:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
> **Note:** Time-based revalidation (`revalidate` on a page or fetch) also needs a `queue` in `open-next.config.ts`. See the OpenNext Cloudflare caching docs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Register a workers.dev subdomain (first-time only)
|
## Step 4 — Register a workers.dev subdomain (first-time only)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API Routes | Yes | Yes |
|
| API Routes | Yes | Yes |
|
||||||
| Setup Complexity | Simple | Moderate |
|
| Setup Complexity | Simple | Moderate |
|
||||||
|
|
||||||
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
|
The repository defaults to **without R2**. Choose **with R2** only if you add pages that need ISR caching.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ No separate draw.io container is needed.
|
|||||||
|
|
||||||
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:
|
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.
|
- On another machine, download `draw.war` for the version in `packages/mcp-server/src/drawio-version.json`, 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.
|
- Or build the image on a machine with internet access and transfer it to the offline network.
|
||||||
|
|
||||||
## Images From Other Websites
|
## Images From Other Websites
|
||||||
|
|||||||
@@ -114,9 +114,12 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
|||||||
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
||||||
|
|
||||||
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
||||||
|
- プレビューは Web アプリと同じキャンバス。AI の変更には印が付き、Ctrl+Z 一回で元に戻せ、選択中の図形を AI が読み取れる
|
||||||
|
- draw.io エディターはパッケージに同梱されているので、インストール後はオフラインでもプレビューが動く(外部の draw.io を使うなら `DRAWIO_BASE_URL` を設定する)
|
||||||
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
||||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
||||||
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
||||||
|
- AI に元に戻す操作を頼める。独自の作図ルールは `~/.next-ai-drawio/instructions.md` に書ける
|
||||||
|
|
||||||
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
|
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
|
||||||
|
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
|||||||
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
|
||||||
| `models` | はい | モデルIDのリスト |
|
| `models` | はい | モデルIDのリスト |
|
||||||
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
|
||||||
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
|
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
|
||||||
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
|
||||||
|
|
||||||
### 備考
|
### 備考
|
||||||
|
|||||||
@@ -68,47 +68,15 @@ cp env.example .env.local
|
|||||||
|
|
||||||
## ステップ 3 — デプロイタイプの選択
|
## ステップ 3 — デプロイタイプの選択
|
||||||
|
|
||||||
### オプション A: R2 なしでのデプロイ(シンプル、無料)
|
### オプション A: R2 なしでのデプロイ(デフォルト、無料)
|
||||||
|
|
||||||
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
|
リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
|
||||||
|
|
||||||
**1. シンプルな `open-next.config.ts` を使用:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({})
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"main": ".open-next/worker.js",
|
|
||||||
"name": "next-ai-draw-io-worker",
|
|
||||||
"compatibility_date": "2025-12-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
||||||
"assets": {
|
|
||||||
"directory": ".open-next/assets",
|
|
||||||
"binding": "ASSETS"
|
|
||||||
},
|
|
||||||
"services": [
|
|
||||||
{
|
|
||||||
"binding": "WORKER_SELF_REFERENCE",
|
|
||||||
"service": "next-ai-draw-io-worker"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 4** へ進んでください。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
|
### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
|
||||||
|
|
||||||
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
|
R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
|
||||||
|
|
||||||
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
|
||||||
|
|
||||||
@@ -127,7 +95,7 @@ export default defineCloudflareConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. `wrangler.jsonc` の設定(R2 あり):**
|
**3. `wrangler.jsonc` に `r2_buckets` を追加:**
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -157,6 +125,8 @@ export default defineCloudflareConfig({
|
|||||||
|
|
||||||
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
|
||||||
|
|
||||||
|
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
|
||||||
@@ -264,4 +234,4 @@ npm run preview
|
|||||||
| API ルート | あり | あり |
|
| API ルート | あり | あり |
|
||||||
| 設定の複雑さ | シンプル | 普通 |
|
| 設定の複雑さ | シンプル | 普通 |
|
||||||
|
|
||||||
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
|
リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ draw.io 用の別コンテナは不要です。
|
|||||||
|
|
||||||
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
ビルドは [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` に書き込みます。ビルドはこのコピーを使います。
|
- 別のマシンで `packages/mcp-server/src/drawio-version.json` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||||
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
||||||
|
|
||||||
## ほかのサイトの画像
|
## ほかのサイトの画像
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const messageItemSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
// The fields read here; every other field is passed on to AI.chatCompletions
|
||||||
const messageSchema = z
|
const messageSchema = z
|
||||||
.object({
|
.object({
|
||||||
messages: z.array(messageItemSchema),
|
messages: z.array(messageItemSchema),
|
||||||
@@ -36,21 +37,6 @@ const messageSchema = z
|
|||||||
stream: z.boolean().optional(),
|
stream: z.boolean().optional(),
|
||||||
tools: z.any().optional(),
|
tools: z.any().optional(),
|
||||||
tool_choice: z.any().optional(),
|
tool_choice: z.any().optional(),
|
||||||
functions: z.any().optional(),
|
|
||||||
function_call: z.any().optional(),
|
|
||||||
temperature: z.number().optional(),
|
|
||||||
top_p: z.number().optional(),
|
|
||||||
max_tokens: z.number().optional(),
|
|
||||||
presence_penalty: z.number().optional(),
|
|
||||||
frequency_penalty: z.number().optional(),
|
|
||||||
stop: z.union([z.string(), z.array(z.string())]).optional(),
|
|
||||||
response_format: z.any().optional(),
|
|
||||||
seed: z.number().optional(),
|
|
||||||
user: z.string().optional(),
|
|
||||||
n: z.number().int().optional(),
|
|
||||||
logit_bias: z.record(z.string(), z.number()).optional(),
|
|
||||||
parallel_tool_calls: z.boolean().optional(),
|
|
||||||
stream_options: z.any().optional(),
|
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
@@ -61,12 +47,6 @@ const ALLOWED_MODELS = [
|
|||||||
"@tx/deepseek-ai/deepseek-v3-0324",
|
"@tx/deepseek-ai/deepseek-v3-0324",
|
||||||
]
|
]
|
||||||
|
|
||||||
const MODEL_ALIASES: Record<string, string> = {
|
|
||||||
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
|
|
||||||
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
|
|
||||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create standardized JSON response
|
* Create standardized JSON response
|
||||||
*/
|
*/
|
||||||
@@ -145,8 +125,16 @@ export async function onRequest({ request, env }: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, model, stream, tools, tool_choice, ...extraParams } =
|
// stream is taken out so it is not passed on: AI.chatCompletions
|
||||||
parseResult.data
|
// only streams, and every caller reads a stream
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
stream: _,
|
||||||
|
tools,
|
||||||
|
tool_choice,
|
||||||
|
...extraParams
|
||||||
|
} = parseResult.data
|
||||||
|
|
||||||
// Validate messages
|
// Validate messages
|
||||||
const userMessages = messages.filter(
|
const userMessages = messages.filter(
|
||||||
@@ -165,14 +153,13 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve model
|
// Resolve model
|
||||||
const requestedModel = model || ALLOWED_MODELS[0]
|
const selectedModel = model || ALLOWED_MODELS[0]
|
||||||
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
||||||
|
|
||||||
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
if (!ALLOWED_MODELS.includes(selectedModel)) {
|
||||||
return createResponse(
|
return createResponse(
|
||||||
{
|
{
|
||||||
error: {
|
error: {
|
||||||
message: `Invalid model: ${requestedModel}.`,
|
message: `Invalid model: ${selectedModel}.`,
|
||||||
type: "invalid_request_error",
|
type: "invalid_request_error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -181,39 +168,10 @@ export async function onRequest({ request, env }: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
|
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isStream = !!stream
|
|
||||||
|
|
||||||
// Non-streaming: return mock response for validation
|
|
||||||
// AI.chatCompletions doesn't support non-streaming mode
|
|
||||||
if (!isStream) {
|
|
||||||
const mockResponse = {
|
|
||||||
id: `chatcmpl-${Date.now()}`,
|
|
||||||
object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: selectedModel,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: "OK",
|
|
||||||
},
|
|
||||||
finish_reason: "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: 1,
|
|
||||||
total_tokens: 11,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return createResponse(mockResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build AI.chatCompletions options for streaming
|
// Build AI.chatCompletions options for streaming
|
||||||
const aiOptions: any = {
|
const aiOptions: any = {
|
||||||
...extraParams,
|
...extraParams,
|
||||||
|
|||||||
Vendored
+3
-69
@@ -2,29 +2,6 @@
|
|||||||
* Type declarations for Electron API exposed via preload script
|
* Type declarations for Electron API exposed via preload script
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Configuration preset interface */
|
|
||||||
interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of applying a preset */
|
|
||||||
interface ApplyPresetResult {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
env?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Proxy configuration interface */
|
/** Proxy configuration interface */
|
||||||
interface ProxyConfig {
|
interface ProxyConfig {
|
||||||
httpProxy?: string
|
httpProxy?: string
|
||||||
@@ -35,7 +12,6 @@ interface ProxyConfig {
|
|||||||
interface SetProxyResult {
|
interface SetProxyResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
devMode?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of setting user locale */
|
/** Result of setting user locale */
|
||||||
@@ -48,31 +24,17 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
/** Main window Electron API */
|
/** Main window Electron API */
|
||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
/** Current platform (darwin, win32, linux) */
|
|
||||||
platform: NodeJS.Platform
|
|
||||||
/** Whether running in Electron environment */
|
/** Whether running in Electron environment */
|
||||||
isElectron: boolean
|
isElectron: boolean
|
||||||
/** Get application version */
|
|
||||||
getVersion: () => Promise<string>
|
|
||||||
/** Minimize the window */
|
|
||||||
minimize: () => void
|
|
||||||
/** Maximize/restore the window */
|
|
||||||
maximize: () => void
|
|
||||||
/** Close the window */
|
|
||||||
close: () => void
|
|
||||||
/** Get proxy configuration */
|
/** Get proxy configuration */
|
||||||
getProxy: () => Promise<ProxyConfig>
|
getProxy: () => Promise<ProxyConfig>
|
||||||
/** Set proxy configuration (saves and restarts server) */
|
/** Set proxy configuration (saves and restarts server) */
|
||||||
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
|
||||||
/** Get user's preferred locale */
|
|
||||||
getUserLocale: () => Promise<
|
|
||||||
"en" | "zh" | "ja" | "zh-Hant" | undefined
|
|
||||||
>
|
|
||||||
/** Set user's preferred locale */
|
/** Set user's preferred locale */
|
||||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||||
/**
|
/**
|
||||||
* Call back after the server restarted on the same port (another
|
* Call back after the server restarted on the same port (proxy
|
||||||
* preset); returns a function that stops the calls
|
* change); returns a function that stops the calls
|
||||||
*/
|
*/
|
||||||
onServerRestarted?: (callback: () => void) => () => void
|
onServerRestarted?: (callback: () => void) => () => void
|
||||||
/** A chat was saved: open this port next launch */
|
/** A chat was saved: open this port next launch */
|
||||||
@@ -80,35 +42,7 @@ declare global {
|
|||||||
/** The page loaded with this many chats */
|
/** The page loaded with this many chats */
|
||||||
chatsLoaded?: (count: number) => Promise<void>
|
chatsLoaded?: (count: number) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings window Electron API */
|
|
||||||
settingsAPI?: {
|
|
||||||
/** Get all configuration presets */
|
|
||||||
getPresets: () => Promise<ConfigPreset[]>
|
|
||||||
/** Get current preset ID */
|
|
||||||
getCurrentPresetId: () => Promise<string | null>
|
|
||||||
/** Get current preset */
|
|
||||||
getCurrentPreset: () => Promise<ConfigPreset | null>
|
|
||||||
/** Save (create or update) a preset */
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => Promise<ConfigPreset>
|
|
||||||
/** Delete a preset */
|
|
||||||
deletePreset: (id: string) => Promise<boolean>
|
|
||||||
/** Apply a preset (sets environment variables and restarts server) */
|
|
||||||
applyPreset: (id: string) => Promise<ApplyPresetResult>
|
|
||||||
/** Close settings window */
|
|
||||||
close: () => void
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type { ProxyConfig }
|
||||||
ApplyPresetResult,
|
|
||||||
ConfigPreset,
|
|
||||||
ProxyConfig,
|
|
||||||
SetProxyResult,
|
|
||||||
SetUserLocaleResult,
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-161
@@ -1,23 +1,9 @@
|
|||||||
import {
|
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
|
||||||
app,
|
|
||||||
BrowserWindow,
|
|
||||||
dialog,
|
|
||||||
Menu,
|
|
||||||
type MenuItemConstructorOptions,
|
|
||||||
shell,
|
|
||||||
} from "electron"
|
|
||||||
import {
|
|
||||||
applyPresetToEnv,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPresetId,
|
|
||||||
setCurrentPreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
|
||||||
import { restartNextServer } from "./next-server"
|
|
||||||
import { showSettingsWindow } from "./settings-window"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build and set the application menu with i18n support
|
* Build and set the application menu with i18n support
|
||||||
|
* (call again when the language changes)
|
||||||
*/
|
*/
|
||||||
export function buildAppMenu(): void {
|
export function buildAppMenu(): void {
|
||||||
const template = getMenuTemplate()
|
const template = getMenuTemplate()
|
||||||
@@ -25,75 +11,6 @@ export function buildAppMenu(): void {
|
|||||||
Menu.setApplicationMenu(menu)
|
Menu.setApplicationMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild the menu (call this when presets change or language changes)
|
|
||||||
*/
|
|
||||||
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
|
* Get the menu template with translations
|
||||||
*/
|
*/
|
||||||
@@ -113,15 +30,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
submenu: [
|
submenu: [
|
||||||
{ role: "about" }, // System-translated
|
{ role: "about" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
|
||||||
label: t.settings,
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{ role: "services" }, // System-translated
|
{ role: "services" }, // System-translated
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "hide" }, // System-translated
|
{ role: "hide" }, // System-translated
|
||||||
@@ -137,19 +45,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
template.push({
|
template.push({
|
||||||
label: t.file,
|
label: t.file,
|
||||||
submenu: [
|
submenu: [
|
||||||
...(isMac
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
label: t.settings,
|
|
||||||
accelerator: "CmdOrCtrl+,",
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]),
|
|
||||||
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
isMac ? { role: "close" } : { role: "quit" }, // System-translated
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -196,9 +91,6 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Configuration menu with presets
|
|
||||||
template.push(buildConfigMenu(t))
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
template.push({
|
template.push({
|
||||||
label: t.window,
|
label: t.window,
|
||||||
@@ -239,54 +131,3 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
|||||||
|
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Configuration menu with presets
|
|
||||||
*/
|
|
||||||
function buildConfigMenu(
|
|
||||||
t: ReturnType<typeof getMenuTranslations>,
|
|
||||||
): MenuItemConstructorOptions {
|
|
||||||
const presets = getAllPresets()
|
|
||||||
const currentPresetId = getCurrentPresetId()
|
|
||||||
|
|
||||||
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
|
|
||||||
label: preset.name,
|
|
||||||
type: "radio",
|
|
||||||
checked: preset.id === currentPresetId,
|
|
||||||
click: async () => {
|
|
||||||
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)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: t.configuration,
|
|
||||||
submenu: [
|
|
||||||
...(presetItems.length > 0
|
|
||||||
? [
|
|
||||||
{ label: t.switchPreset, enabled: false },
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
...presetItems,
|
|
||||||
{ type: "separator" } as MenuItemConstructorOptions,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label:
|
|
||||||
presetItems.length > 0
|
|
||||||
? t.managePresets
|
|
||||||
: t.addConfigurationPreset,
|
|
||||||
click: () => {
|
|
||||||
const win = BrowserWindow.getFocusedWindow()
|
|
||||||
showSettingsWindow(win || undefined)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+36
-549
@@ -1,570 +1,57 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { app } from "electron"
|
||||||
import {
|
import * as fs from "fs"
|
||||||
existsSync,
|
import * as path from "path"
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs"
|
|
||||||
import path from "node:path"
|
|
||||||
import { app, safeStorage } from "electron"
|
|
||||||
|
|
||||||
/**
|
export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const
|
||||||
* Fields that contain sensitive data and should be encrypted
|
export type UserLocale = (typeof USER_LOCALES)[number]
|
||||||
*/
|
|
||||||
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
|
|
||||||
|
|
||||||
/**
|
// Earlier versions kept their configuration presets in this file too; the
|
||||||
* Prefix to identify encrypted values
|
// name stays so the saved language survives the upgrade
|
||||||
*/
|
const CONFIG_FILE = "config-presets.json"
|
||||||
const ENCRYPTED_PREFIX = "encrypted:"
|
|
||||||
|
|
||||||
/**
|
function getConfigPath(): string {
|
||||||
* Check if safeStorage encryption is available
|
return path.join(app.getPath("userData"), CONFIG_FILE)
|
||||||
*/
|
|
||||||
function isEncryptionAvailable(): boolean {
|
|
||||||
return safeStorage.isEncryptionAvailable()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track if we've already warned about plaintext storage
|
* Load the config file as an object ({} if it is missing or invalid)
|
||||||
*/
|
*/
|
||||||
let hasWarnedAboutPlaintext = false
|
function loadConfig(): Record<string, unknown> {
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a sensitive value using safeStorage
|
|
||||||
* Warns if encryption is not available (API key stored in plaintext)
|
|
||||||
*/
|
|
||||||
function encryptValue(value: string): string {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
if (!hasWarnedAboutPlaintext) {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ SECURITY WARNING: safeStorage not available. " +
|
|
||||||
"API keys will be stored in PLAINTEXT. " +
|
|
||||||
"On Linux, install gnome-keyring or similar for secure storage.",
|
|
||||||
)
|
|
||||||
hasWarnedAboutPlaintext = true
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = safeStorage.encryptString(value)
|
const configPath = getConfigPath()
|
||||||
return ENCRYPTED_PREFIX + encrypted.toString("base64")
|
if (fs.existsSync(configPath)) {
|
||||||
} catch (error) {
|
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||||
console.error("Encryption failed:", error)
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||||
// Fail secure: don't store if encryption fails
|
return data
|
||||||
throw new Error(
|
|
||||||
"Failed to encrypt API key. Cannot securely store credentials.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (!isEncryptionAvailable()) {
|
|
||||||
console.warn(
|
|
||||||
"Cannot decrypt value: safeStorage encryption is not available",
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
|
|
||||||
const buffer = Buffer.from(base64Data, "base64")
|
|
||||||
return safeStorage.decryptString(buffer)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to decrypt value:", error)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function encryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const encrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (encrypted[field]) {
|
|
||||||
encrypted[field] = encryptValue(encrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt sensitive fields in a config object
|
|
||||||
*/
|
|
||||||
function decryptConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const decrypted = { ...config }
|
|
||||||
for (const field of SENSITIVE_FIELDS) {
|
|
||||||
if (decrypted[field]) {
|
|
||||||
decrypted[field] = decryptValue(decrypted[field] as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration preset interface
|
|
||||||
*/
|
|
||||||
export interface ConfigPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
createdAt: number
|
|
||||||
updatedAt: number
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER?: string
|
|
||||||
AI_MODEL?: string
|
|
||||||
AI_API_KEY?: string
|
|
||||||
AI_BASE_URL?: string
|
|
||||||
TEMPERATURE?: string
|
|
||||||
[key: string]: string | undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration file structure
|
|
||||||
*/
|
|
||||||
interface ConfigPresetsFile {
|
|
||||||
version: 1
|
|
||||||
currentPresetId: string | null
|
|
||||||
presets: ConfigPreset[]
|
|
||||||
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG_FILE_NAME = "config-presets.json"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the config file
|
|
||||||
*/
|
|
||||||
function getConfigFilePath(): string {
|
|
||||||
const userDataPath = app.getPath("userData")
|
|
||||||
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
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(content) as ConfigPresetsFile
|
|
||||||
|
|
||||||
// Decrypt sensitive fields in each preset
|
|
||||||
data.presets = data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: decryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
}))
|
|
||||||
|
|
||||||
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,
|
|
||||||
presets: [],
|
|
||||||
userLocale: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save presets to the config file
|
|
||||||
* 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")
|
|
||||||
|
|
||||||
// Ensure the directory exists
|
|
||||||
if (!existsSync(userDataPath)) {
|
|
||||||
mkdirSync(userDataPath, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt sensitive fields before saving
|
|
||||||
const dataToSave: ConfigPresetsFile = {
|
|
||||||
...data,
|
|
||||||
presets: data.presets.map((preset) => ({
|
|
||||||
...preset,
|
|
||||||
config: encryptConfig(preset.config) as ConfigPreset["config"],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all presets
|
|
||||||
*/
|
|
||||||
export function getAllPresets(): ConfigPreset[] {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.presets
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset ID
|
|
||||||
*/
|
|
||||||
export function getCurrentPresetId(): string | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
return data.currentPresetId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current preset
|
|
||||||
*/
|
|
||||||
export function getCurrentPreset(): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
if (!data.currentPresetId) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return data.presets.find((p) => p.id === data.currentPresetId) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new preset
|
|
||||||
*/
|
|
||||||
export function createPreset(
|
|
||||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
|
|
||||||
): ConfigPreset {
|
|
||||||
const data = loadPresets()
|
|
||||||
const now = Date.now()
|
|
||||||
|
|
||||||
const newPreset: ConfigPreset = {
|
|
||||||
id: randomUUID(),
|
|
||||||
name: preset.name,
|
|
||||||
config: preset.config,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.push(newPreset)
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return newPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing preset
|
|
||||||
*/
|
|
||||||
export function updatePreset(
|
|
||||||
id: string,
|
|
||||||
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
|
|
||||||
): ConfigPreset | null {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPreset: ConfigPreset = {
|
|
||||||
...data.presets[index],
|
|
||||||
...updates,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets[index] = updatedPreset
|
|
||||||
savePresets(data)
|
|
||||||
|
|
||||||
return updatedPreset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a preset
|
|
||||||
*/
|
|
||||||
export function deletePreset(id: string): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
const index = data.presets.findIndex((p) => p.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
data.presets.splice(index, 1)
|
|
||||||
|
|
||||||
// Clear current preset (and its env vars) if it was deleted
|
|
||||||
if (data.currentPresetId === id) {
|
|
||||||
data.currentPresetId = null
|
|
||||||
setPresetEnv(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the current preset
|
|
||||||
*/
|
|
||||||
export function setCurrentPreset(id: string | null): boolean {
|
|
||||||
const data = loadPresets()
|
|
||||||
|
|
||||||
let preset: ConfigPreset | null = null
|
|
||||||
if (id !== null) {
|
|
||||||
preset = data.presets.find((p) => p.id === id) || null
|
|
||||||
if (!preset) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPresetEnv(preset)
|
|
||||||
data.currentPresetId = id
|
|
||||||
savePresets(data)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
|
|
||||||
*/
|
|
||||||
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|
||||||
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
|
|
||||||
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
|
|
||||||
google: {
|
|
||||||
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
||||||
baseUrl: "GOOGLE_BASE_URL",
|
|
||||||
},
|
|
||||||
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
|
|
||||||
openrouter: {
|
|
||||||
apiKey: "OPENROUTER_API_KEY",
|
|
||||||
baseUrl: "OPENROUTER_BASE_URL",
|
|
||||||
},
|
|
||||||
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
|
|
||||||
siliconflow: {
|
|
||||||
apiKey: "SILICONFLOW_API_KEY",
|
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
|
||||||
},
|
|
||||||
modelscope: {
|
|
||||||
apiKey: "MODELSCOPE_API_KEY",
|
|
||||||
baseUrl: "MODELSCOPE_BASE_URL",
|
|
||||||
},
|
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
|
||||||
// bedrock doesn't use API keys in the same way
|
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
|
||||||
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a preset's config to environment variables
|
|
||||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
else if (
|
|
||||||
key === "AI_API_KEY" &&
|
|
||||||
provider &&
|
|
||||||
PROVIDER_ENV_MAP[provider]
|
|
||||||
) {
|
|
||||||
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
|
|
||||||
if (providerApiKey) {
|
|
||||||
env[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) {
|
|
||||||
env[providerBaseUrl] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Apply other env vars directly
|
|
||||||
else {
|
|
||||||
env[key] = value
|
|
||||||
}
|
}
|
||||||
|
console.error("Ignoring invalid config file:", data)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("Failed to write dev preset env:", error)
|
console.error("Failed to load config file:", error)
|
||||||
}
|
}
|
||||||
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply preset environment variables to the current process
|
* Get the menu locale the user chose, if any
|
||||||
* Returns the environment variables that were applied
|
|
||||||
*/
|
*/
|
||||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
export function getUserLocale(): UserLocale | undefined {
|
||||||
const data = loadPresets()
|
const { userLocale } = loadConfig()
|
||||||
const preset = data.presets.find((p) => p.id === id)
|
return (USER_LOCALES as readonly unknown[]).includes(userLocale)
|
||||||
|
? (userLocale as UserLocale)
|
||||||
if (!preset) {
|
: undefined
|
||||||
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)
|
* Save the menu locale the user chose
|
||||||
*/
|
*/
|
||||||
export function applyCurrentPresetToEnv(): void {
|
export function setUserLocale(locale: UserLocale): void {
|
||||||
setPresetEnv(getCurrentPreset())
|
const config = { ...loadConfig(), userLocale: locale }
|
||||||
}
|
const configPath = getConfigPath()
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
||||||
/**
|
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||||
* Get user's preferred locale from config
|
// a truncated file
|
||||||
* Returns undefined if not set
|
const tempPath = `${configPath}.tmp`
|
||||||
*/
|
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||||
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
|
fs.renameSync(tempPath, configPath)
|
||||||
const data = loadPresets()
|
|
||||||
return data.userLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set user's preferred locale in config
|
|
||||||
*/
|
|
||||||
export function setUserLocale(
|
|
||||||
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
|
|
||||||
): void {
|
|
||||||
const data = loadPresets()
|
|
||||||
data.userLocale = locale === null ? undefined : locale
|
|
||||||
savePresets(data)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { parse } from "dotenv"
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,69 +29,13 @@ export function loadEnvFile(): void {
|
|||||||
console.log("No .env file found, using system environment variables")
|
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
|
* Parse and load environment variables from a file
|
||||||
*/
|
*/
|
||||||
function loadEnvFromFile(filePath: string): void {
|
function loadEnvFromFile(filePath: string): void {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, "utf-8")
|
const parsed = parse(fs.readFileSync(filePath, "utf-8"))
|
||||||
const lines = content.split("\n")
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim()
|
|
||||||
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue
|
|
||||||
|
|
||||||
const equalIndex = trimmed.indexOf("=")
|
|
||||||
if (equalIndex === -1) continue
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, equalIndex).trim()
|
|
||||||
let value = trimmed.slice(equalIndex + 1).trim()
|
|
||||||
|
|
||||||
const quote = value[0]
|
|
||||||
const closingQuote =
|
|
||||||
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
|
|
||||||
if (
|
|
||||||
closingQuote > 0 &&
|
|
||||||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 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
|
// Don't override existing environment variables
|
||||||
if (!(key in process.env)) {
|
if (!(key in process.env)) {
|
||||||
process.env[key] = value
|
process.env[key] = value
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||||
import { buildAppMenu } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
|
||||||
import { loadEnvFile } from "./env-loader"
|
import { loadEnvFile } from "./env-loader"
|
||||||
import { registerIpcHandlers } from "./ipc-handlers"
|
import { registerIpcHandlers } from "./ipc-handlers"
|
||||||
import { startNextServer, stopNextServer } from "./next-server"
|
import { startNextServer, stopNextServer } from "./next-server"
|
||||||
import { applyProxyToEnv } from "./proxy-manager"
|
import { applyProxyToEnv } from "./proxy-manager"
|
||||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
|
||||||
import {
|
import {
|
||||||
createWindow,
|
createWindow,
|
||||||
getAppUrl,
|
getAppUrl,
|
||||||
@@ -36,14 +34,8 @@ if (!gotTheLock) {
|
|||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
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
|
// Register IPC handlers
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
registerSettingsWindowHandlers()
|
|
||||||
|
|
||||||
// Build application menu
|
// Build application menu
|
||||||
buildAppMenu()
|
buildAppMenu()
|
||||||
|
|||||||
+11
-171
@@ -1,17 +1,6 @@
|
|||||||
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
|
import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
|
||||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
import { buildAppMenu } from "./app-menu"
|
||||||
import {
|
import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
|
||||||
type ConfigPreset,
|
|
||||||
createPreset,
|
|
||||||
deletePreset,
|
|
||||||
getAllPresets,
|
|
||||||
getCurrentPreset,
|
|
||||||
getCurrentPresetId,
|
|
||||||
getUserLocale,
|
|
||||||
setCurrentPreset,
|
|
||||||
setUserLocale,
|
|
||||||
updatePreset,
|
|
||||||
} from "./config-manager"
|
|
||||||
import { restartNextServer } from "./next-server"
|
import { restartNextServer } from "./next-server"
|
||||||
import { noteNoChats, rememberChatPort } from "./port-manager"
|
import { noteNoChats, rememberChatPort } from "./port-manager"
|
||||||
import {
|
import {
|
||||||
@@ -23,37 +12,9 @@ import {
|
|||||||
import { isAppUrl } from "./window-manager"
|
import { isAppUrl } from "./window-manager"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed configuration keys for presets
|
* Register an IPC handler that only answers the app's own pages (on the app
|
||||||
* This whitelist prevents arbitrary environment variable injection
|
* server). A main window that somehow ends up on an external site still
|
||||||
*/
|
* gets the preload API, so its calls must be rejected here.
|
||||||
const ALLOWED_CONFIG_KEYS = new Set([
|
|
||||||
"AI_PROVIDER",
|
|
||||||
"AI_MODEL",
|
|
||||||
"AI_API_KEY",
|
|
||||||
"AI_BASE_URL",
|
|
||||||
"TEMPERATURE",
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize preset config to only include allowed keys
|
|
||||||
*/
|
|
||||||
function sanitizePresetConfig(
|
|
||||||
config: Record<string, string | undefined>,
|
|
||||||
): Record<string, string | undefined> {
|
|
||||||
const sanitized: Record<string, string | undefined> = {}
|
|
||||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
|
||||||
if (key in config && typeof config[key] === "string") {
|
|
||||||
sanitized[key] = config[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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[]>(
|
function handle<Args extends unknown[]>(
|
||||||
channel: string,
|
channel: string,
|
||||||
@@ -61,7 +22,7 @@ function handle<Args extends unknown[]>(
|
|||||||
): void {
|
): void {
|
||||||
ipcMain.handle(channel, (event, ...args) => {
|
ipcMain.handle(channel, (event, ...args) => {
|
||||||
const url = event.senderFrame?.url
|
const url = event.senderFrame?.url
|
||||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
if (!isAppUrl(url)) {
|
||||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||||
}
|
}
|
||||||
return listener(event, ...(args as Args))
|
return listener(event, ...(args as Args))
|
||||||
@@ -72,12 +33,6 @@ function handle<Args extends unknown[]>(
|
|||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
*/
|
*/
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ==================== App Info ====================
|
|
||||||
|
|
||||||
handle("get-version", () => {
|
|
||||||
return app.getVersion()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Where the chats are ====================
|
// ==================== Where the chats are ====================
|
||||||
|
|
||||||
// The page saved a chat, or loaded without any: decides which port
|
// The page saved a chat, or loaded without any: decides which port
|
||||||
@@ -87,116 +42,6 @@ export function registerIpcHandlers(): void {
|
|||||||
if (count === 0) noteNoChats()
|
if (count === 0) noteNoChats()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Window Controls ====================
|
|
||||||
|
|
||||||
ipcMain.on("window-minimize", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
win?.minimize()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on("window-maximize", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
if (win?.isMaximized()) {
|
|
||||||
win.unmaximize()
|
|
||||||
} else {
|
|
||||||
win?.maximize()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on("window-close", (event) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
|
||||||
win?.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Config Presets ====================
|
|
||||||
|
|
||||||
handle("config-presets:get-all", () => {
|
|
||||||
return getAllPresets()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:get-current", () => {
|
|
||||||
return getCurrentPreset()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:get-current-id", () => {
|
|
||||||
return getCurrentPresetId()
|
|
||||||
})
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
throw new Error("Invalid preset name")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize config to only allow whitelisted keys
|
|
||||||
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
|
|
||||||
|
|
||||||
if (preset.id) {
|
|
||||||
// Update existing preset
|
|
||||||
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
|
|
||||||
const created = createPreset({
|
|
||||||
name: preset.name.trim(),
|
|
||||||
config: sanitizedConfig,
|
|
||||||
})
|
|
||||||
rebuildAppMenu()
|
|
||||||
return created
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:apply", async (_event, id: string) => {
|
|
||||||
try {
|
|
||||||
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,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to restart server",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
|
||||||
return setCurrentPreset(id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== Proxy Settings ====================
|
// ==================== Proxy Settings ====================
|
||||||
|
|
||||||
handle("get-proxy", () => {
|
handle("get-proxy", () => {
|
||||||
@@ -228,7 +73,7 @@ export function registerIpcHandlers(): void {
|
|||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
// In development, env vars are already applied
|
// In development, env vars are already applied
|
||||||
// Next.js dev server may need manual restart
|
// Next.js dev server may need manual restart
|
||||||
return { success: true, devMode: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: restart Next.js server to pick up new env vars
|
// Production: restart Next.js server to pick up new env vars
|
||||||
@@ -247,20 +92,15 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ==================== User Locale ====================
|
// ==================== User Locale ====================
|
||||||
|
|
||||||
handle("get-user-locale", () => {
|
|
||||||
return getUserLocale()
|
|
||||||
})
|
|
||||||
|
|
||||||
handle("set-user-locale", (_event, locale: string) => {
|
handle("set-user-locale", (_event, locale: string) => {
|
||||||
// Validate locale is one of the supported values
|
if (!(USER_LOCALES as readonly string[]).includes(locale)) {
|
||||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
|
||||||
return { success: false, error: "Invalid locale" }
|
return { success: false, error: "Invalid locale" }
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
|
setUserLocale(locale as UserLocale)
|
||||||
// Rebuild the menu to reflect the new locale
|
// Rebuild the menu to reflect the new locale
|
||||||
rebuildAppMenu()
|
buildAppMenu()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+27
-113
@@ -3,71 +3,35 @@
|
|||||||
* Translations for menu labels that don't use Electron's built-in roles
|
* Translations for menu labels that don't use Electron's built-in roles
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getUserLocale } from "./config-manager"
|
import { getUserLocale, type UserLocale } from "./config-manager"
|
||||||
|
|
||||||
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
|
export type MenuLocale = UserLocale
|
||||||
|
|
||||||
export interface MenuTranslations {
|
|
||||||
// App menu (macOS only)
|
|
||||||
settings: string
|
|
||||||
|
|
||||||
|
const en = {
|
||||||
// File menu
|
// File menu
|
||||||
file: string
|
file: "File",
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
edit: string
|
edit: "Edit",
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
view: string
|
view: "View",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: string
|
|
||||||
switchPreset: string
|
|
||||||
managePresets: string
|
|
||||||
addConfigurationPreset: string
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: string
|
window: "Window",
|
||||||
|
|
||||||
// Help menu
|
// Help menu
|
||||||
help: string
|
help: "Help",
|
||||||
documentation: string
|
documentation: "Documentation",
|
||||||
reportIssue: string
|
reportIssue: "Report Issue",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuTranslations = typeof en
|
||||||
|
|
||||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||||
en: {
|
en,
|
||||||
// App menu
|
|
||||||
settings: "Settings...",
|
|
||||||
|
|
||||||
// File menu
|
|
||||||
file: "File",
|
|
||||||
|
|
||||||
// Edit menu
|
|
||||||
edit: "Edit",
|
|
||||||
|
|
||||||
// View menu
|
|
||||||
view: "View",
|
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "Configuration",
|
|
||||||
switchPreset: "Switch Preset",
|
|
||||||
managePresets: "Manage Presets...",
|
|
||||||
addConfigurationPreset: "Add Configuration Preset...",
|
|
||||||
|
|
||||||
// Window menu
|
|
||||||
window: "Window",
|
|
||||||
|
|
||||||
// Help menu
|
|
||||||
help: "Help",
|
|
||||||
documentation: "Documentation",
|
|
||||||
reportIssue: "Report Issue",
|
|
||||||
},
|
|
||||||
|
|
||||||
zh: {
|
zh: {
|
||||||
// App menu
|
|
||||||
settings: "设置...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "文件",
|
file: "文件",
|
||||||
|
|
||||||
@@ -77,12 +41,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "查看",
|
view: "查看",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切换预设",
|
|
||||||
managePresets: "管理预设...",
|
|
||||||
addConfigurationPreset: "添加配置预设...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "窗口",
|
window: "窗口",
|
||||||
|
|
||||||
@@ -93,9 +51,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
ja: {
|
ja: {
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "ファイル",
|
file: "ファイル",
|
||||||
|
|
||||||
@@ -105,12 +60,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "表示",
|
view: "表示",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "設定",
|
|
||||||
switchPreset: "プリセット切り替え",
|
|
||||||
managePresets: "プリセット管理...",
|
|
||||||
addConfigurationPreset: "設定プリセットを追加...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "ウインドウ",
|
window: "ウインドウ",
|
||||||
|
|
||||||
@@ -121,9 +70,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"zh-Hant": {
|
"zh-Hant": {
|
||||||
// App menu
|
|
||||||
settings: "設定...",
|
|
||||||
|
|
||||||
// File menu
|
// File menu
|
||||||
file: "檔案",
|
file: "檔案",
|
||||||
|
|
||||||
@@ -133,12 +79,6 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
// View menu
|
// View menu
|
||||||
view: "檢視",
|
view: "檢視",
|
||||||
|
|
||||||
// Configuration menu
|
|
||||||
configuration: "配置",
|
|
||||||
switchPreset: "切換預設",
|
|
||||||
managePresets: "管理預設...",
|
|
||||||
addConfigurationPreset: "新增配置預設...",
|
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
window: "視窗",
|
window: "視窗",
|
||||||
|
|
||||||
@@ -150,33 +90,22 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get menu translations for a given locale
|
* Get menu translations for a locale (English for an unknown one)
|
||||||
* Falls back to English if locale is not supported
|
|
||||||
*/
|
*/
|
||||||
export function getMenuTranslations(locale: string): MenuTranslations {
|
export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
|
||||||
// Check for zh-Hant before normalizing
|
return translations[locale] ?? translations.en
|
||||||
if (
|
|
||||||
locale === "zh-Hant" ||
|
|
||||||
locale.toLowerCase().startsWith("zh-hant") ||
|
|
||||||
locale.toLowerCase().startsWith("zh-tw") ||
|
|
||||||
locale.toLowerCase().startsWith("zh-hk")
|
|
||||||
) {
|
|
||||||
return translations["zh-Hant"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
|
|
||||||
const normalized = locale.toLowerCase().split("-")[0]
|
|
||||||
|
|
||||||
if (normalized === "zh") return translations.zh
|
|
||||||
if (normalized === "ja") return translations.ja
|
|
||||||
return translations.en
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect system locale from Electron app
|
* Get locale from stored preference or system default
|
||||||
* Returns one of: "en", "zh", "ja", "zh-Hant"
|
* Checks config file for user's language preference first
|
||||||
*/
|
*/
|
||||||
export function detectSystemLocale(appLocale: string): MenuLocale {
|
export function getPreferredLocale(appLocale: string): MenuLocale {
|
||||||
|
const savedLocale = getUserLocale()
|
||||||
|
if (savedLocale) {
|
||||||
|
return savedLocale
|
||||||
|
}
|
||||||
|
|
||||||
const lower = appLocale.toLowerCase()
|
const lower = appLocale.toLowerCase()
|
||||||
|
|
||||||
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
|
||||||
@@ -188,24 +117,9 @@ export function detectSystemLocale(appLocale: string): MenuLocale {
|
|||||||
return "zh-Hant"
|
return "zh-Hant"
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = lower.split("-")[0]
|
const language = lower.split("-")[0]
|
||||||
|
|
||||||
if (normalized === "zh") return "zh"
|
if (language === "zh") return "zh"
|
||||||
if (normalized === "ja") return "ja"
|
if (language === "ja") return "ja"
|
||||||
return "en"
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get locale from stored preference or system default
|
|
||||||
* Checks config file for user's language preference first
|
|
||||||
*/
|
|
||||||
export function getPreferredLocale(appLocale: string): MenuLocale {
|
|
||||||
// Try to get from saved preference first
|
|
||||||
const savedLocale = getUserLocale()
|
|
||||||
if (savedLocale) {
|
|
||||||
return savedLocale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to system locale
|
|
||||||
return detectSystemLocale(appLocale)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { setAppUrl } from "./window-manager"
|
|||||||
let serverProcess: UtilityProcess | null = null
|
let serverProcess: UtilityProcess | null = null
|
||||||
|
|
||||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||||
// preset switches) can't leave two servers running
|
// proxy changes) can't leave two servers running
|
||||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
|
||||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||||
@@ -21,18 +21,6 @@ function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to the standalone server resources
|
|
||||||
* In packaged app: resources/standalone
|
|
||||||
* In development: .next/standalone
|
|
||||||
*/
|
|
||||||
function getResourcePath(): string {
|
|
||||||
if (app.isPackaged) {
|
|
||||||
return path.join(process.resourcesPath, "standalone")
|
|
||||||
}
|
|
||||||
return path.join(app.getAppPath(), ".next", "standalone")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the server to be ready by polling the health endpoint
|
* Wait for the server to be ready by polling the health endpoint
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +49,8 @@ export function startNextServer(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer(): Promise<string> {
|
async function startServer(): Promise<string> {
|
||||||
const resourcePath = getResourcePath()
|
// The standalone server shipped in the packaged app's resources
|
||||||
|
const resourcePath = path.join(process.resourcesPath, "standalone")
|
||||||
const serverPath = path.join(resourcePath, "server.js")
|
const serverPath = path.join(resourcePath, "server.js")
|
||||||
|
|
||||||
console.log(`Starting Next.js server from: ${resourcePath}`)
|
console.log(`Starting Next.js server from: ${resourcePath}`)
|
||||||
@@ -75,7 +64,7 @@ async function startServer(): Promise<string> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find an available port (random in production, fixed in development)
|
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
|
||||||
const port = await findAvailablePort()
|
const port = await findAvailablePort()
|
||||||
console.log(`Using port: ${port}`)
|
console.log(`Using port: ${port}`)
|
||||||
|
|
||||||
@@ -86,7 +75,7 @@ async function startServer(): Promise<string> {
|
|||||||
HOSTNAME: "127.0.0.1",
|
HOSTNAME: "127.0.0.1",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
// The preset keys are the user's own, not a server's
|
// The local server's env keys are the user's own, not a shared server's
|
||||||
NEXT_AI_DRAWIO_DESKTOP: "1",
|
NEXT_AI_DRAWIO_DESKTOP: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +86,7 @@ async function startServer(): Promise<string> {
|
|||||||
|
|
||||||
// Set cache directory to a writable location (user's app data folder)
|
// Set cache directory to a writable location (user's app data folder)
|
||||||
// This is necessary because the packaged app might be on a read-only volume
|
// This is necessary because the packaged app might be on a read-only volume
|
||||||
if (app.isPackaged) {
|
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
|
||||||
const cacheDir = path.join(app.getPath("userData"), "cache")
|
|
||||||
env.NEXT_CACHE_DIR = cacheDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy existing environment variables
|
// Copy existing environment variables
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { app } from "electron"
|
|||||||
* (localStorage is origin-specific, so changing ports loses all saved data)
|
* (localStorage is origin-specific, so changing ports loses all saved data)
|
||||||
*/
|
*/
|
||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
|
||||||
development: 6002,
|
|
||||||
// Legacy production port — tried first to preserve localStorage for existing users
|
// Legacy production port — tried first to preserve localStorage for existing users
|
||||||
legacyProduction: 61337,
|
legacyProduction: 61337,
|
||||||
// New production port below the ephemeral range (49152-65535)
|
// New production port below the ephemeral range (49152-65535)
|
||||||
@@ -108,9 +106,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - Uses the legacy port (61337), then 13370, to preserve localStorage;
|
||||||
* - In production: uses the legacy port (61337), then 13370, to preserve
|
* 13370 first when only it has saved chats
|
||||||
* localStorage; 13370 first when only it has saved chats
|
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
* - Last resort: lets the OS assign a port (port 0)
|
* - Last resort: lets the OS assign a port (port 0)
|
||||||
*
|
*
|
||||||
@@ -118,10 +115,7 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const preferredPort = PORT_CONFIG.production
|
||||||
const preferredPort = isDev
|
|
||||||
? PORT_CONFIG.development
|
|
||||||
: PORT_CONFIG.production
|
|
||||||
|
|
||||||
// Try to reuse cached port if requested and available
|
// Try to reuse cached port if requested and available
|
||||||
if (reuseExisting && allocatedPort !== null) {
|
if (reuseExisting && allocatedPort !== null) {
|
||||||
@@ -135,19 +129,18 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, first the port where a chat was last saved. Without
|
// First the port where a chat was last saved. Without one, the legacy
|
||||||
// one, the legacy port first to preserve existing users' data, unless
|
// port first to preserve existing users' data, unless only the new port
|
||||||
// only the new port has data: their app started on 13370 while Windows
|
// has data: their app started on 13370 while Windows reserved 61337,
|
||||||
// reserved 61337, and 61337 being free now would hide it
|
// and 61337 being free now would hide it
|
||||||
const chatPort = isDev ? null : readChatPort()
|
const chatPort = readChatPort()
|
||||||
const candidates = isDev
|
const candidates =
|
||||||
? [preferredPort]
|
chatPort !== null
|
||||||
: chatPort !== null
|
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
||||||
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
|
: hasStoredData(PORT_CONFIG.production) &&
|
||||||
: hasStoredData(PORT_CONFIG.production) &&
|
!hasStoredData(PORT_CONFIG.legacyProduction)
|
||||||
!hasStoredData(PORT_CONFIG.legacyProduction)
|
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
||||||
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
|
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
||||||
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
|
|
||||||
for (const port of candidates) {
|
for (const port of candidates) {
|
||||||
if (await isPortAvailable(port)) {
|
if (await isPortAvailable(port)) {
|
||||||
allocatedPort = port
|
allocatedPort = port
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import path from "node:path"
|
|
||||||
import { app, BrowserWindow, ipcMain } from "electron"
|
|
||||||
|
|
||||||
let settingsWindow: BrowserWindow | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and show the settings window
|
|
||||||
*/
|
|
||||||
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
|
|
||||||
// If settings window already exists, focus it
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine path to settings preload script
|
|
||||||
// In compiled output: dist-electron/preload/settings.js
|
|
||||||
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
|
|
||||||
|
|
||||||
// Determine path to settings HTML
|
|
||||||
// In packaged app: app.asar/dist-electron/settings/index.html
|
|
||||||
// In development: electron/settings/index.html
|
|
||||||
const settingsHtmlPath = app.isPackaged
|
|
||||||
? path.join(__dirname, "..", "settings", "index.html")
|
|
||||||
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
|
|
||||||
|
|
||||||
settingsWindow = new BrowserWindow({
|
|
||||||
width: 600,
|
|
||||||
height: 700,
|
|
||||||
minWidth: 500,
|
|
||||||
minHeight: 500,
|
|
||||||
parent: parentWindow,
|
|
||||||
modal: false,
|
|
||||||
show: false,
|
|
||||||
title: "Settings - Next AI Draw.io",
|
|
||||||
webPreferences: {
|
|
||||||
preload: preloadPath,
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
settingsWindow.loadFile(settingsHtmlPath)
|
|
||||||
|
|
||||||
settingsWindow.once("ready-to-show", () => {
|
|
||||||
settingsWindow?.show()
|
|
||||||
})
|
|
||||||
|
|
||||||
settingsWindow.on("closed", () => {
|
|
||||||
settingsWindow = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the settings window if it exists
|
|
||||||
*/
|
|
||||||
export function closeSettingsWindow(): void {
|
|
||||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
|
||||||
settingsWindow.close()
|
|
||||||
settingsWindow = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if settings window is open
|
|
||||||
*/
|
|
||||||
export function isSettingsWindowOpen(): boolean {
|
|
||||||
return settingsWindow !== null && !settingsWindow.isDestroyed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register settings window IPC handlers
|
|
||||||
*/
|
|
||||||
export function registerSettingsWindowHandlers(): void {
|
|
||||||
ipcMain.on("settings:close", () => {
|
|
||||||
closeSettingsWindow()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Point the main window at the restarted app server (it can come up on a
|
* 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
|
* different port). On the same port the page is told so it can fetch the
|
||||||
* server models instead of sending the old preset's choice; it is not
|
* server's models again; it is not reloaded, which would drop unsent
|
||||||
* reloaded, which would drop unsent attachments.
|
* attachments.
|
||||||
*/
|
*/
|
||||||
export function setAppUrl(url: string): void {
|
export function setAppUrl(url: string): void {
|
||||||
if (url === appUrl) {
|
if (url === appUrl) {
|
||||||
|
|||||||
@@ -4,27 +4,15 @@ import { contextBridge, ipcRenderer } from "electron"
|
|||||||
* Expose safe APIs to the renderer process
|
* Expose safe APIs to the renderer process
|
||||||
*/
|
*/
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
// Platform information
|
|
||||||
platform: process.platform,
|
|
||||||
|
|
||||||
// Check if running in Electron
|
// Check if running in Electron
|
||||||
isElectron: true,
|
isElectron: true,
|
||||||
|
|
||||||
// Application version
|
|
||||||
getVersion: () => ipcRenderer.invoke("get-version"),
|
|
||||||
|
|
||||||
// Window controls (optional, for custom title bar)
|
|
||||||
minimize: () => ipcRenderer.send("window-minimize"),
|
|
||||||
maximize: () => ipcRenderer.send("window-maximize"),
|
|
||||||
close: () => ipcRenderer.send("window-close"),
|
|
||||||
|
|
||||||
// Proxy settings
|
// Proxy settings
|
||||||
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
getProxy: () => ipcRenderer.invoke("get-proxy"),
|
||||||
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
|
||||||
ipcRenderer.invoke("set-proxy", config),
|
ipcRenderer.invoke("set-proxy", config),
|
||||||
|
|
||||||
// User locale settings
|
// User locale settings
|
||||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
|
||||||
setUserLocale: (locale: string) =>
|
setUserLocale: (locale: string) =>
|
||||||
ipcRenderer.invoke("set-user-locale", locale),
|
ipcRenderer.invoke("set-user-locale", locale),
|
||||||
|
|
||||||
@@ -33,7 +21,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
chatSaved: () => ipcRenderer.invoke("chat-saved"),
|
||||||
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
|
||||||
|
|
||||||
// The server restarted on the same port (another preset)
|
// The server restarted on the same port (proxy change)
|
||||||
onServerRestarted: (callback: () => void) => {
|
onServerRestarted: (callback: () => void) => {
|
||||||
const listener = () => callback()
|
const listener = () => callback()
|
||||||
ipcRenderer.on("server-restarted", listener)
|
ipcRenderer.on("server-restarted", listener)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* Preload script for settings window
|
|
||||||
* Exposes APIs for managing configuration presets
|
|
||||||
*/
|
|
||||||
import { contextBridge, ipcRenderer } from "electron"
|
|
||||||
|
|
||||||
// Expose settings API to the renderer process
|
|
||||||
contextBridge.exposeInMainWorld("settingsAPI", {
|
|
||||||
// Get all presets
|
|
||||||
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
|
|
||||||
|
|
||||||
// Get current preset ID
|
|
||||||
getCurrentPresetId: () =>
|
|
||||||
ipcRenderer.invoke("config-presets:get-current-id"),
|
|
||||||
|
|
||||||
// Get current preset
|
|
||||||
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
|
|
||||||
|
|
||||||
// Save (create or update) a preset
|
|
||||||
savePreset: (preset: {
|
|
||||||
id?: string
|
|
||||||
name: string
|
|
||||||
config: Record<string, string | undefined>
|
|
||||||
}) => ipcRenderer.invoke("config-presets:save", preset),
|
|
||||||
|
|
||||||
// Delete a preset
|
|
||||||
deletePreset: (id: string) =>
|
|
||||||
ipcRenderer.invoke("config-presets:delete", id),
|
|
||||||
|
|
||||||
// Apply a preset (sets environment variables and restarts server)
|
|
||||||
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
|
|
||||||
|
|
||||||
// Close settings window
|
|
||||||
close: () => ipcRenderer.send("settings:close"),
|
|
||||||
})
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
|
|
||||||
<title>Settings - Next AI Draw.io</title>
|
|
||||||
<link rel="stylesheet" href="./settings.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="deprecation-notice">
|
|
||||||
<strong>⚠️ Deprecation Notice</strong>
|
|
||||||
<p>This settings panel will be removed in a future update.</p>
|
|
||||||
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1>Configuration Presets</h1>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2>Presets</h2>
|
|
||||||
<div id="preset-list" class="preset-list">
|
|
||||||
<!-- Presets will be loaded here -->
|
|
||||||
</div>
|
|
||||||
<button id="add-preset-btn" class="btn btn-primary">
|
|
||||||
+ Add New Preset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Preset Modal -->
|
|
||||||
<div id="preset-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 id="modal-title">Add Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<form id="preset-form">
|
|
||||||
<input type="hidden" id="preset-id">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="preset-name">Preset Name *</label>
|
|
||||||
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-provider">AI Provider</label>
|
|
||||||
<select id="ai-provider">
|
|
||||||
<option value="">-- Select Provider --</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
|
||||||
<option value="google">Google AI (Gemini)</option>
|
|
||||||
<option value="azure">Azure OpenAI</option>
|
|
||||||
<option value="bedrock">AWS Bedrock</option>
|
|
||||||
<option value="openrouter">OpenRouter</option>
|
|
||||||
<option value="deepseek">DeepSeek</option>
|
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
|
||||||
<option value="modelscope">ModelScope</option>
|
|
||||||
<option value="ollama">Ollama (Local)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-model">Model ID</label>
|
|
||||||
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
|
|
||||||
<div class="hint">The model identifier to use with the selected provider</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-api-key">API Key</label>
|
|
||||||
<input type="password" id="ai-api-key" placeholder="Your API key">
|
|
||||||
<div class="hint">This will be stored locally on your device</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="ai-base-url">Base URL (Optional)</label>
|
|
||||||
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
|
|
||||||
<div class="hint">Custom API endpoint URL</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="temperature">Temperature (Optional)</label>
|
|
||||||
<input type="text" id="temperature" placeholder="0.7">
|
|
||||||
<div class="hint">Controls randomness (0.0 - 2.0)</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
|
||||||
<div id="delete-modal" class="modal-overlay">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>Delete Preset</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
|
|
||||||
<p class="delete-warning">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
|
|
||||||
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toast notification -->
|
|
||||||
<div id="toast" class="toast"></div>
|
|
||||||
|
|
||||||
<script src="./settings.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg-primary: #ffffff;
|
|
||||||
--bg-secondary: #f5f5f5;
|
|
||||||
--bg-hover: #e8e8e8;
|
|
||||||
--text-primary: #1a1a1a;
|
|
||||||
--text-secondary: #666666;
|
|
||||||
--border-color: #e0e0e0;
|
|
||||||
--accent-color: #0066cc;
|
|
||||||
--accent-hover: #0052a3;
|
|
||||||
--danger-color: #dc3545;
|
|
||||||
--success-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--bg-primary: #1a1a1a;
|
|
||||||
--bg-secondary: #2d2d2d;
|
|
||||||
--bg-hover: #3d3d3d;
|
|
||||||
--text-primary: #ffffff;
|
|
||||||
--text-secondary: #a0a0a0;
|
|
||||||
--border-color: #404040;
|
|
||||||
--accent-color: #4da6ff;
|
|
||||||
--accent-hover: #66b3ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border: 1px solid #ffc107;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice strong {
|
|
||||||
color: #856404;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice p {
|
|
||||||
color: #856404;
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.deprecation-notice {
|
|
||||||
background-color: #332701;
|
|
||||||
border-color: #665200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deprecation-notice strong,
|
|
||||||
.deprecation-notice p {
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
|
||||||
sans-serif;
|
|
||||||
background-color: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 560px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-card.active {
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 1px var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-badge {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-info {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-overlay {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 100;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay.show {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 480px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group .hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: inline-block;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: var(--bg-primary);
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 200;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.success {
|
|
||||||
background: var(--success-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.error {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline style replacements */
|
|
||||||
.delete-warning {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
// Settings page JavaScript
|
|
||||||
// This file handles the UI interactions for the settings window
|
|
||||||
|
|
||||||
let presets = []
|
|
||||||
let currentPresetId = null
|
|
||||||
let editingPresetId = null
|
|
||||||
let deletingPresetId = null
|
|
||||||
|
|
||||||
// DOM Elements
|
|
||||||
const presetList = document.getElementById("preset-list")
|
|
||||||
const addPresetBtn = document.getElementById("add-preset-btn")
|
|
||||||
const presetModal = document.getElementById("preset-modal")
|
|
||||||
const deleteModal = document.getElementById("delete-modal")
|
|
||||||
const presetForm = document.getElementById("preset-form")
|
|
||||||
const modalTitle = document.getElementById("modal-title")
|
|
||||||
const toast = document.getElementById("toast")
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
const presetIdField = document.getElementById("preset-id")
|
|
||||||
const presetNameField = document.getElementById("preset-name")
|
|
||||||
const aiProviderField = document.getElementById("ai-provider")
|
|
||||||
const aiModelField = document.getElementById("ai-model")
|
|
||||||
const aiApiKeyField = document.getElementById("ai-api-key")
|
|
||||||
const aiBaseUrlField = document.getElementById("ai-base-url")
|
|
||||||
const temperatureField = document.getElementById("temperature")
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
const cancelBtn = document.getElementById("cancel-btn")
|
|
||||||
const saveBtn = document.getElementById("save-btn")
|
|
||||||
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
|
|
||||||
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
|
||||||
await loadPresets()
|
|
||||||
setupEventListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load presets from main process
|
|
||||||
async function loadPresets() {
|
|
||||||
try {
|
|
||||||
presets = await window.settingsAPI.getPresets()
|
|
||||||
currentPresetId = await window.settingsAPI.getCurrentPresetId()
|
|
||||||
renderPresets()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load presets:", error)
|
|
||||||
showToast("Failed to load presets", "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render presets list
|
|
||||||
function renderPresets() {
|
|
||||||
if (presets.length === 0) {
|
|
||||||
presetList.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No presets configured yet.</p>
|
|
||||||
<p>Add a preset to quickly switch between different AI configurations.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
presetList.innerHTML = presets
|
|
||||||
.map((preset) => {
|
|
||||||
const isActive = preset.id === currentPresetId
|
|
||||||
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
|
|
||||||
<div class="preset-header">
|
|
||||||
<span class="preset-name">${escapeHtml(preset.name)}</span>
|
|
||||||
${isActive ? '<span class="preset-badge">Active</span>' : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-info">
|
|
||||||
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
|
|
||||||
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
|
|
||||||
</div>
|
|
||||||
<div class="preset-actions">
|
|
||||||
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
|
|
||||||
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
|
|
||||||
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
|
|
||||||
// Add event listeners to buttons
|
|
||||||
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
applyPreset(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openEditModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
openDeleteModal(btn.dataset.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
function setupEventListeners() {
|
|
||||||
addPresetBtn.addEventListener("click", () => openAddModal())
|
|
||||||
cancelBtn.addEventListener("click", () => closeModal())
|
|
||||||
saveBtn.addEventListener("click", () => savePreset())
|
|
||||||
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
|
|
||||||
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
|
|
||||||
|
|
||||||
// Close modal on overlay click
|
|
||||||
presetModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === presetModal) closeModal()
|
|
||||||
})
|
|
||||||
deleteModal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === deleteModal) closeDeleteModal()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle Enter key in form
|
|
||||||
presetForm.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
savePreset()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open add modal
|
|
||||||
function openAddModal() {
|
|
||||||
editingPresetId = null
|
|
||||||
modalTitle.textContent = "Add Preset"
|
|
||||||
presetForm.reset()
|
|
||||||
presetIdField.value = ""
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open edit modal
|
|
||||||
function openEditModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
editingPresetId = id
|
|
||||||
modalTitle.textContent = "Edit Preset"
|
|
||||||
|
|
||||||
presetIdField.value = preset.id
|
|
||||||
presetNameField.value = preset.name
|
|
||||||
aiProviderField.value = preset.config.AI_PROVIDER || ""
|
|
||||||
aiModelField.value = preset.config.AI_MODEL || ""
|
|
||||||
aiApiKeyField.value = preset.config.AI_API_KEY || ""
|
|
||||||
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
|
|
||||||
temperatureField.value = preset.config.TEMPERATURE || ""
|
|
||||||
|
|
||||||
presetModal.classList.add("show")
|
|
||||||
presetNameField.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
presetModal.classList.remove("show")
|
|
||||||
editingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open delete modal
|
|
||||||
function openDeleteModal(id) {
|
|
||||||
const preset = presets.find((p) => p.id === id)
|
|
||||||
if (!preset) return
|
|
||||||
|
|
||||||
deletingPresetId = id
|
|
||||||
document.getElementById("delete-preset-name").textContent = preset.name
|
|
||||||
deleteModal.classList.add("show")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close delete modal
|
|
||||||
function closeDeleteModal() {
|
|
||||||
deleteModal.classList.remove("show")
|
|
||||||
deletingPresetId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save preset
|
|
||||||
async function savePreset() {
|
|
||||||
const name = presetNameField.value.trim()
|
|
||||||
if (!name) {
|
|
||||||
showToast("Please enter a preset name", "error")
|
|
||||||
presetNameField.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = {
|
|
||||||
id: editingPresetId || undefined,
|
|
||||||
name: name,
|
|
||||||
config: {
|
|
||||||
AI_PROVIDER: aiProviderField.value || undefined,
|
|
||||||
AI_MODEL: aiModelField.value.trim() || undefined,
|
|
||||||
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
|
|
||||||
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
|
|
||||||
TEMPERATURE: temperatureField.value.trim() || undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove undefined values
|
|
||||||
Object.keys(preset.config).forEach((key) => {
|
|
||||||
if (preset.config[key] === undefined) {
|
|
||||||
delete preset.config[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// closeModal() clears editingPresetId, so remember it for the toast
|
|
||||||
const isEdit = Boolean(editingPresetId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
saveBtn.disabled = true
|
|
||||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.savePreset(preset)
|
|
||||||
await loadPresets()
|
|
||||||
closeModal()
|
|
||||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save preset:", error)
|
|
||||||
showToast("Failed to save preset", "error")
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false
|
|
||||||
saveBtn.textContent = "Save"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm delete
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deletingPresetId) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
deleteConfirmBtn.disabled = true
|
|
||||||
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
|
|
||||||
|
|
||||||
await window.settingsAPI.deletePreset(deletingPresetId)
|
|
||||||
await loadPresets()
|
|
||||||
closeDeleteModal()
|
|
||||||
showToast("Preset deleted", "success")
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete preset:", error)
|
|
||||||
showToast("Failed to delete preset", "error")
|
|
||||||
} finally {
|
|
||||||
deleteConfirmBtn.disabled = false
|
|
||||||
deleteConfirmBtn.textContent = "Delete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply preset
|
|
||||||
async function applyPreset(id) {
|
|
||||||
try {
|
|
||||||
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true
|
|
||||||
btn.innerHTML = '<span class="loading"></span>'
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await window.settingsAPI.applyPreset(id)
|
|
||||||
if (result.success) {
|
|
||||||
showToast("Preset applied, server restarting...", "success")
|
|
||||||
} else {
|
|
||||||
showToast(result.error || "Failed to apply preset", "error")
|
|
||||||
}
|
|
||||||
} 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider display label
|
|
||||||
function getProviderLabel(provider) {
|
|
||||||
const labels = {
|
|
||||||
openai: "OpenAI",
|
|
||||||
anthropic: "Anthropic",
|
|
||||||
google: "Google AI",
|
|
||||||
azure: "Azure OpenAI",
|
|
||||||
bedrock: "AWS Bedrock",
|
|
||||||
openrouter: "OpenRouter",
|
|
||||||
deepseek: "DeepSeek",
|
|
||||||
siliconflow: "SiliconFlow",
|
|
||||||
modelscope: "ModelScope",
|
|
||||||
ollama: "Ollama",
|
|
||||||
}
|
|
||||||
return labels[provider] || provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show toast notification
|
|
||||||
function showToast(message, type = "") {
|
|
||||||
toast.textContent = message
|
|
||||||
toast.className = "toast show" + (type ? ` ${type}` : "")
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove("show")
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement("div")
|
|
||||||
div.textContent = text
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
+18
-5
@@ -8,6 +8,7 @@ AI_PROVIDER=bedrock
|
|||||||
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
|
||||||
# The first one becomes the default and the rest appear in the model picker.
|
# The first one becomes the default and the rest appear in the model picker.
|
||||||
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
|
||||||
|
# There, apiKeyEnv may also be an array of env var names: each request picks one that has a value at random.
|
||||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
# 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
|
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||||
|
|
||||||
@@ -28,8 +29,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# OpenAI Configuration
|
# OpenAI Configuration
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
|
||||||
# OPENAI_ORGANIZATION=org-... # Optional
|
|
||||||
# OPENAI_PROJECT=proj_... # Optional
|
|
||||||
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
|
||||||
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||||
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
|
||||||
@@ -142,6 +141,18 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# ADMIN_PASSWORD=your-admin-password
|
# ADMIN_PASSWORD=your-admin-password
|
||||||
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
|
||||||
|
|
||||||
|
# Quota & Validation (Optional, also editable in the admin panel)
|
||||||
|
# Quota enforcement is disabled unless DYNAMODB_QUOTA_TABLE is set.
|
||||||
|
# DYNAMODB_QUOTA_TABLE=next-ai-draw-io-quota # DynamoDB table that stores the per-IP counters
|
||||||
|
# DYNAMODB_REGION=ap-northeast-1 # Region of the table
|
||||||
|
# QUOTA_TIMEZONE=UTC # Timezone for the daily reset boundary
|
||||||
|
# DAILY_REQUEST_LIMIT=10 # Requests per IP per day
|
||||||
|
# DAILY_TOKEN_LIMIT=200000 # Tokens per IP per day
|
||||||
|
# TPM_LIMIT=20000 # Tokens per minute
|
||||||
|
# ENABLE_VLM_VALIDATION=true # Visually validate generated diagrams with a vision model
|
||||||
|
# VALIDATION_MODEL= # Falls back to the default AI model when empty
|
||||||
|
# VALIDATION_TIMEOUT=30000 # Validation timeout in ms
|
||||||
|
|
||||||
# Draw.io Configuration (Optional)
|
# Draw.io Configuration (Optional)
|
||||||
# By default the app serves its own copy of draw.io from /drawio (downloaded into
|
# 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
|
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
|
||||||
@@ -156,9 +167,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
|
|
||||||
# PDF Input Feature (Optional)
|
# PDF Input Feature (Optional)
|
||||||
# Enable PDF file upload to extract text and generate diagrams
|
# PDF file upload to extract text and generate diagrams
|
||||||
# Enabled by default. Set to "false" to disable.
|
|
||||||
# ENABLE_PDF_INPUT=true
|
|
||||||
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
|
||||||
|
|
||||||
# Security Settings (Optional)
|
# Security Settings (Optional)
|
||||||
@@ -184,6 +193,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# It also turns off the MCP server advertisement in Settings > About.
|
# It also turns off the MCP server advertisement in Settings > About.
|
||||||
# NEXT_PUBLIC_SELFHOSTED=true
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
|
# Analytics (Optional)
|
||||||
|
# Google Analytics measurement ID; nothing is loaded when unset
|
||||||
|
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||||
|
|
||||||
# Minimax Configuration (Optional)
|
# Minimax Configuration (Optional)
|
||||||
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
||||||
# MINIMAX_API_KEY=your_minimax_api_key
|
# MINIMAX_API_KEY=your_minimax_api_key
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user