Compare commits

..

34 Commits

Author SHA1 Message Date
dayuan.jiang
9656fc0cfe fix(parse-url): block SSRF via private/internal URLs
/api/parse-url accepted any URL the user submitted, fetched it via
@extractus/article-extractor, and returned the body as Markdown. With
ALLOW_PRIVATE_URLS unset (the default after #600) the SSRF guard
short-circuited entirely, so an unauthenticated POST could probe
container ports, read AWS IMDS / GCP metadata, and reach same-VPC
internal services.

- parse-url now always rejects private URLs regardless of
  ALLOW_PRIVATE_URLS. The flag's only legitimate use case is local
  LLM provider baseUrl overrides (validate-model, chat); article
  extraction has no business fetching internal hosts. Local LLM
  setups (Ollama, LM Studio, etc.) are unaffected.
- Strip a trailing dot from the hostname before equality checks so
  the FQDN form "localhost." (which still resolves to 127.0.0.1) is
  caught by the existing string match.

Known follow-ups (not addressed here):
- DNS rebinding: hostnames are matched as strings; a public domain
  resolving to 127.0.0.1 (e.g. localtest.me) is not caught.
- HTTP redirects: @extractus/article-extractor uses cross-fetch with
  default redirect: "follow" and exposes no hook, so a public URL
  302-ing to an internal host still leaks.
2026-05-21 23:50:30 +09:00
Dayuan Jiang
1115b2d2cd chore: bump version to 0.4.16 (#843) 2026-05-21 09:28:55 +09:00
renovate[bot]
08afb6dd34 chore(deps): update dependency next to v16.2.6 [security] (#842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 09:05:06 +09:00
renovate[bot]
c703159e00 fix(deps): update core framework packages (major) (#724)
* fix(deps): update core framework packages

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 09:01:42 +09:00
renovate[bot]
ee75408136 chore(deps): update dependency electron to v39.8.5 [security] (#789)
* chore(deps): update dependency electron to v39.8.5 [security]

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 09:01:03 +09:00
renovate[bot]
2ed1a64ae3 fix(deps): update minor and patch dependencies (#829)
* fix(deps): update minor and patch dependencies

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 08:59:18 +09:00
Dayuan Jiang
2f2d75961d chore: remove dead code from previous #815 fix attempts (#841)
PR #840 fixed issue #815 in the Electron main process via
will-prevent-unload + preventDefault. The renderer-side workarounds
introduced by previous fix attempts (#642, #648) are no longer needed
and never had effect for their stated purpose.

Removed:
- configuration={ confirmExit: false } in DrawIoEmbed
  confirmExit is not a recognized draw.io config key (zero matches in
  jgraph/drawio source). This was always dead code.

- modified=0 / keepmodified=0 URL parameters
  Per drawio source (app.min.js:14898), these only suppress the
  post-save modified-flag clearing — they do not prevent edits from
  setting editor.modified=true. They were ineffective for blocking
  beforeunload prompts and actually prevented draw.io from clearing
  its modified flag after save.

- canPersist / canPersistChecked state and isIndexedDBUsable() probe
  Their only purpose was gating the dead config above. Removing them
  also removes a startup delay before the iframe renders.

- handleDrawioAutoSave wrapper
  After PR #780 stripped its body, it was a pure passthrough useCallback.
  Now passes handleDiagramAutoSave directly to onAutoSave.

- withDB / isClosingError / resetDBPromise / onversionchange / onclose
  / terminated handlers in lib/session-storage.ts and lib/template-storage.ts
  PR #648 added these to recover from 'IDBDatabase: connection is closing'
  errors that PR #642's first land caused via db.close() on the shared
  singleton. That bug was already fixed in c5de1a1 (re-land of #642),
  three minutes before PR #648 commits started. The retry handlers
  defend against multi-tab / version-change scenarios that cannot occur
  in this single-instance Electron app (requestSingleInstanceLock).
  template-storage.ts copied the same pattern when introduced by #773.

Verified:
- npx tsc --noEmit passes
- Manual test in dev mode: session save/load works, template create works,
  diagram-only persistence works.
2026-05-21 08:37:14 +09:00
Dayuan Jiang
5406778dd6 fix(electron): override draw.io iframe beforeunload to allow window close (fixes #815) (#840)
The draw.io iframe registers a window.onbeforeunload handler that returns
a non-empty string whenever its internal editor.modified flag is true.
After the user edits text in a shape, that flag is set and never cleared.

Per Electron BrowserWindow docs, returning a non-void value from any
beforeunload handler in the page tree silently cancels the window close
without showing a dialog. This is what caused the X button (and Cmd+Q)
to do nothing for users who had typed in a shape.

Calling event.preventDefault() in will-prevent-unload tells Electron to
ignore the iframe's beforeunload return value and proceed with the close.
The host app already persists diagrams via autosave + visibilitychange,
so the prompt was unnecessary.

Verified by reproducing the bug, applying the fix, and re-testing.
2026-05-20 23:33:05 +09:00
果子
bb65a8c07a fix(e2e): resolve iframe toolbar strict mode violation (#837)
* fix(e2e): resolve strict mode violation in iframe test

Use .first() with [title*="Diagram"] selector to avoid matching multiple elements.

Fixes CI failure in E2E Tests job.

* fix(e2e): use .first() to resolve strict mode violation

* style: fix biome formatting in iframe test

* style: fix biome formatting in iframe test

* fix(e2e): use .or().first() to handle both text and title selectors

* fix(e2e): increase timeout for draw.io toolbar visibility check

* fix(e2e): filter visible elements to avoid selecting hidden toolbar
2026-05-19 09:52:02 +09:00
果子
4e223b6237 feat: add all Draw.io themes to settings panel (#835)
* feat: add all Draw.io themes to settings panel

Add all available Draw.io themes (kennedy, atlas, dark, min, sketch, simple)
to the settings panel dropdown. Previously only min and sketch were available
as a toggle button.

Changes:
- Replace the Draw.io style toggle button with a dropdown selector
- Expand theme type from "min" | "sketch" to include all 6 themes
- Update localStorage validation to accept all themes
- Update handler from toggle to direct theme selection

Closes #499

* fix: localize theme labels, tighten DrawioTheme typing, sync dark param

- Move DRAWIO_THEMES + DrawioTheme to lib/drawio-themes.ts; reuse in
  page.tsx, chat-panel.tsx and settings-dialog.tsx instead of `string`
- Localize theme dropdown labels (Dark/Minimal/Sketch/Simple) in
  en/zh/ja/zh-Hant; keep proper-noun themes (Kennedy/Atlas) as-is
- Drop trailing colon from drawioStyleDescription and remove dead
  switchTo/minimal/sketch keys in all 4 dictionaries
- Auto-sync drawio dark URL param when ui="dark" is selected
- Add aria-label to drawio-style SelectTrigger

* fix: use kennedy as default theme and label it "Default"

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-05-15 23:14:20 +09:00
Octopus
c60e3930a3 fix: use createDeepSeek for kimi provider to handle reasoning_content in multi-turn conversations (fixes #824) (#825)
Kimi thinking models (e.g. kimi-k2.6) return reasoning_content in their
responses. The previous createOpenAI-based implementation silently ignored
this field, so reasoning was never captured or replayed in subsequent turns.
Switching to createDeepSeek (which natively understands reasoning_content)
ensures that reasoning context is preserved across conversation turns,
resolving the "cannot interact a second time" error with Kimi k2.6.

This mirrors the existing doubao provider pattern, which already uses
createDeepSeek for kimi-based models routed through Doubao.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-15 14:02:26 +09:00
Octopus
5c8ae4d6d7 fix: always re-fetch access code config when settings dialog opens (#816)
When ACCESS_CODE_LIST is configured on the server, the settings dialog
was not showing the access code input field in two cases:

1. Stale localStorage cache: if a user had previously visited without
   ACCESS_CODE_LIST enabled, the cached value of accessCodeRequired=false
   would be used indefinitely, hiding the password input.

2. Race condition on first visit: the dialog could open triggered
   by an auth error before the async fetch to /api/config completed,
   showing a blank settings dialog with no access code field.

Fix by re-fetching /api/config whenever the dialog opens (on open
change) instead of only once on mount with a cache guard. The cached
value in localStorage is still updated on success, keeping the fast
initial render intact while ensuring the dialog always reflects the
server configuration.

Fixes #811

Co-authored-by: octo-patch <octo-patch@github.com>
2026-05-15 10:52:51 +09:00
Dayuan Jiang
f965f3fa2e chore: align biome schema version with CLI latest (#832)
Bumps biome.json $schema from 2.4.4 to 2.4.14 so the repo schema
matches the version the 'Auto Format' workflow installs via
@biomejs/biome@latest. Also applies the one auto-fix the newer
version produces (export ordering in electron/electron.d.ts).

Fixes the spurious 'This PR has formatting issues' CI failure that
was blocking fork PRs unrelated to formatting.
2026-05-07 22:43:37 +09:00
LaaraibAhmed
73eefc7aa6 fix: CSS/UI issue in model-config-dialog (#818)
Co-authored-by: Laaraib Ahmed <laaraibahmed@Laaraibs-MacBook-Pro.local>
2026-05-07 22:14:46 +09:00
renovate[bot]
a8d27088ef chore(deps): update dependency @xmldom/xmldom to v0.9.10 [security] (#821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 02:57:27 +00:00
Dayuan Jiang
d4454beb9a chore: bump version to 0.4.15 (#810) 2026-04-14 23:57:40 +09:00
Octopus
171174378c fix: allow QvQ (Qwen Visual QA) models to use image input (#808)
QvQ models (e.g. qvq-72b-preview, qvq-max) are visual reasoning models
from the Qwen family that support image input. When accessed via providers
that prefix model names with 'qwen/' (e.g., OpenRouter), these models
contain 'qwen' in their ID but lack the 'vl' or 'vision' indicator.

This caused supportsImageInput() to incorrectly return false for model
IDs like 'qwen/qvq-72b-preview', blocking image uploads for vision-capable
models.

Add 'qvq' as an explicit exception in the Qwen text-model check so that
QvQ models are correctly allowed to receive image input regardless of the
provider prefix.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-13 20:05:57 +09:00
Zhichang Yu
eadc2c2629 feat: add rpm target to electron-builder for Linux distribution (#806)
Added rpm target (x64 + arm64) alongside existing deb and AppImage
targets. No CI changes required since electron-builder handles both
deb and rpm generation under the --linux flag.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:18:50 +09:00
Dayuan Jiang
c77af86011 fix: improve create_new_diagram tool description to prevent misuse and bump to v0.2.0 (#803) 2026-04-10 17:18:24 +09:00
Dayuan Jiang
0cd1260172 fix: show all enabled panels on chat lobby instead of only templates (#802) 2026-04-10 14:33:56 +09:00
Khanh Thanh
49bfb51b10 feat: Update some tool demos in english about page (#598)
* feat: update about page examples with new diagrams

Replace old cloud architecture and cat examples with ResNet50, RAG,
Authentication, Agile Scrum, and Open Innovation diagrams.
Update all three language versions (EN, CN, JA) for consistency.

* fix: correct ResNet50 prompt text and keep prompts in English

Update the ResNet50 example prompt to match the actual diagram.
Keep all prompt texts in English across CN and JA about pages
since prompts are what users type into the AI.

* docs: update README examples to match about page

Replace GCP, AWS, Azure examples with ResNet50, RAG, Auth,
Agile Scrum, and Open Innovation. Keep Animated Transformer
and Cat Sketch. Keep prompts in English across all versions.

* docs: remove ResNet50 and Agile Scrum examples from READMEs

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-04-10 13:11:34 +09:00
Dayuan Jiang
ccd9c1f48e fix: reduce CPU usage during large XML streaming (#801)
* fix: reduce CPU usage during large XML streaming

Skip Prism syntax highlighting while tool call is streaming — use plain
<pre> during streaming and only run Prism once at completion. Also throttle
scrollIntoView to once per 150ms to avoid layout thrashing.

Profiled with Playwright + CDP: for 200-cell diagrams the longest
browser task dropped from 6.7s to ~450ms and total long-task time
fell from ~10.9s to ~1.1s.

* fix: add trailing edge to scroll throttle

Ensures the chat scrolls to the bottom after streaming ends, even if
the last messages update arrives during the 150ms throttle window.
2026-04-10 11:12:50 +09:00
Octopus
622aa8683d fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) (#780) 2026-04-10 10:30:05 +09:00
Octopus
43ddb7a999 fix: allow Qwen3.5 models to use image input (fixes #799) (#800)
Qwen3.5 models deployed via vLLM natively support image input, but the
supportsImageInput() check was incorrectly blocking them. The function
only exempted qwen3.5-plus and qwen3.5-flash variants, missing the base
qwen3.5 model name.

Simplify the exception to cover all qwen3.5 variants with a single
substring check on "qwen3.5", since it is a common prefix of all three.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-10 10:23:39 +09:00
Dayuan Jiang
b9fdf9538c chore: remove MCP preview labels (#790)
MCP server is no longer in preview. Remove "(Preview)" headings from
READMEs, the purple PREVIEW badge from the UI, and the preview i18n keys.
2026-04-06 10:17:38 +09:00
Dayuan Jiang
1f31692701 chore: always bundle latest draw.io version in Electron builds (#792)
Remove pinned v29.3.5 tag so the build always clones the latest draw.io.
This adds the Animated GIF export and other new features to the Electron app.

Closes #770
2026-04-06 10:14:50 +09:00
Dayuan Jiang
31819f413c fix: add 10MB body size limit to MCP HTTP endpoints (#791)
All three POST handlers (/api/state, /api/restore, /api/history-svg)
now use a shared readBody() helper that enforces a 10MB limit and
returns 413 if exceeded, preventing memory exhaustion from oversized
requests.

Bumps @next-ai-drawio/mcp-server to 0.1.19.
2026-04-06 09:14:20 +09:00
Dayuan Jiang
41c410c2ba fix: bind MCP server HTTP to 127.0.0.1 only (#787)
The embedded HTTP sidecar was using server.listen(port) without a host
argument, which defaults to 0.0.0.0 (all interfaces). This exposed the
server to the local network. Now explicitly binds to 127.0.0.1.

Also excludes release/ from tsconfig to fix pre-existing TS errors.

Bumps @next-ai-drawio/mcp-server to 0.1.18.
2026-04-06 09:04:38 +09:00
Dayuan Jiang
f593901fee fix: zoom reset on drag and IndexedDB version conflict (#776)
- Fix zoom resetting when dragging items (#775): removed useEffect that
  called load() on every autosave-triggered chartXML change, which reset
  the viewport. Moved diagram restore logic to onDrawioLoad where it
  only fires on remount.

- Fix IndexedDB VersionError: template-storage.ts shared the same DB
  name as session-storage.ts but at version 2, causing session storage
  to fail with "requested version (1) < existing version (2)". Give
  templates their own DB ("next-ai-drawio-templates").
2026-04-03 16:44:42 +09:00
Octopus
6c6cf98019 fix: merge system messages for custom OpenAI-compatible endpoints (#774)
* fix: merge system messages for custom OpenAI endpoints (fixes #734)

When using the OpenAI provider with a custom base URL (e.g., vLLM, LMStudio),
the app sends two system messages to the API. Open-source model chat templates
(Qwen, Llama, etc.) enforce that system messages must appear at the beginning
and reject multiple system message blocks, causing the error:
'System message must be at the beginning.'

Treat custom OpenAI endpoints (client-provided base URL or OPENAI_BASE_URL env
var) the same as other known single-system providers by merging both system
messages into one before sending.

* fix: also detect custom OpenAI endpoint from serverModelConfig.baseUrlEnv

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-04-03 12:27:29 +09:00
astordu
f5ea5a0edd feat: add personal My Templates library alongside Quick Examples (#773)
* 增加了ralph自动化编程梳理

* feat: US-001 - 为模板库建立独立的 IndexedDB 存储层

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-002 - 在空聊天状态用我的模板库替换官方示例

- 将 ChatLobby 中的 Quick Examples 替换为 TemplatePanel
- 当没有历史会话时,展示完整的模板库面板
- 当有历史会话时,展示可折叠的 "My Templates" 区域
- 使用 TemplatePanel 组件展示用户的个人模板库

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-004 - Provide template creation flow

- Create TemplateCreateDialog component with form fields for prompt, title, description, tags, and pinned
- Add i18n translations for template creation UI in en, zh, zh-Hant, ja
- Update TemplatePanel to integrate the create dialog
- Support initialPrompt prop for pre-filling from current input
- Validate required prompt field (empty prompt not allowed)
- Auto-generate default title from first 20 chars of  Pin templates appear at top of list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-005 - 为模板卡片提供编辑、删除和复制操作

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-006 - Send template directly on click and record usage statistics

- Implement click-to-send template functionality with confirmation dialog
- Add clickCount and runCount increment logic
- Display runCount and lastUsedAt on template card
- Add i18n translations for confirmation dialog (en, zh, zh-Hant, ja)
- Pass onSendTemplate and currentInput props through component hierarchy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-007 - Support template search, pin and default sorting

- Add search bar to TemplatePanel with real-time filtering by title, description, and tags
- Add pin/unpin toggle button on template cards (uses Bookmark icon with fill indicator)
- Search uses existing searchTemplates function from template-storage
- Sort uses existing sortTemplates function (pinned desc, runCount desc, lastUsedAt desc, updatedAt desc)
- Show empty state with Search icon when search returns no results
- List re-sorts immediately after pin/unpin toggle
- Add i18n keys: searchPlaceholder, searchNoResults, pin, unpin for all 4 languages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-008 - Support saving current input as template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-009 - Support saving historical user message as template

- Add "Save as Template" button to user messages
- Pre-fill prompt with original user message text
- Only show on user messages,- Dialog opens TemplateCreateDialog on click
- Template appears in list immediately after saving

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: US-010 - Support template import and export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove local-only dirs from git tracking (.agents, .cursor, scripts, screenshots)

These directories contain local IDE configs, agent scripts, and
dev tooling that should not be part of the upstream repository.
Added them to .gitignore to prevent future accidental commits.

* chore: remove AGENTS.md from git tracking

* fix: add missing i18n keys for template export/import (en/zh/zh-Hant/ja)

* fix: review fixes for my-templates PR

- Fix fragile querySelector("form") with id-based lookup
- Add objectStoreNames.contains guards for IndexedDB upgrades
- Remove duplicate TemplateSchema, import from template-storage
- Revert contributor-specific .gitignore additions
- Fix broken i18n placeholders and missing translations (zh/ja/zh-Hant)
- Remove unused setFiles prop from ChatLobby
- Remove tags feature (unnecessary complexity)
- Improve template card layout: overlay icons on hover, align stats
- Add break-all and overflow-hidden for long prompt text in dialogs
- Move incrementClickCount into sendTemplate for accurate tracking
- Use Intl.RelativeTimeFormat for locale-aware relative time

* feat: restore Quick Examples panel and add lobby panel visibility settings

Bring back the ExamplePanel as a third collapsible section in ChatLobby
alongside Recent Chats and My Templates. Add toggle switches in Settings
to show/hide each lobby panel, persisted via localStorage.

* fix: template send race condition, import defaults, and empty title bug

- Use flushSync instead of setTimeout(0) in handleSendTemplate to
  ensure React state is flushed before form submission
- Explicitly validate and default all fields in importTemplates to
  prevent undefined counters from malformed import JSON
- Fall back to existing title in edit dialog instead of writing undefined

* fix: address Copilot review comments and remove PRD file

- Fix fallback formatLastUsed returning "Not used yet" for recent usage
- Remove dead mounted flag in TemplatePanel useEffect
- Respect panel visibility settings in no-history lobby state
- Reject empty/whitespace titles in import validation
- Trim title/prompt in importTemplates with default title fallback
- Remove tasks/prd-template-library-replaces-examples.md from repo

* fix: remove double sort, dead code, redundant stats, and break-all CSS

- Remove redundant sortTemplates call in loadTemplates (already sorted by getAllTemplates)
- Remove unused createEmptyTemplateInput and sortTemplates import
- Show "Not used yet" only once for unused templates instead of twice
- Use break-words instead of break-all on prompt textareas

---------

Co-authored-by: 杜雷 <dreamfly@126.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-04-03 12:16:01 +09:00
zongxi1115
cb8127920c feat: add xmlsvg export option (#761)
* feat: add xmlsvg export option

* fix: avoid atob for xmlsvg export to prevent UTF-8 corruption

Pass the data URL directly (like PNG export) instead of decoding
with atob(). atob() + Blob([string]) double-encodes non-ASCII
characters (Chinese, Japanese, emoji), corrupting the output file.

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-04-03 08:50:57 +09:00
Dayuan Jiang
29538782fc docs: add AI tools guidelines to CONTRIBUTING.md (#771) 2026-04-01 22:05:46 +09:00
renovate[bot]
8d6d33bfc9 fix(deps): update minor and patch dependencies (#767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 04:50:36 +00:00
55 changed files with 5483 additions and 1794 deletions

View File

@@ -61,6 +61,15 @@ For **significant changes** (new features, architecture changes, large refactors
CI will run the full test suite on your PR. CI will run the full test suite on your PR.
## Using AI Tools
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
1. **Review the code** — understand what was generated, don't just commit blindly
2. **Write a PR description** — explain what changed and why
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
## Code Review ## Code Review
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR: This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:

View File

@@ -37,7 +37,7 @@ jobs:
- name: Download draw.io static files for offline use - name: Download draw.io static files for offline use
run: | run: |
rm -rf public/drawio rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/ cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF rm -rf public/drawio/WEB-INF
@@ -70,7 +70,7 @@ jobs:
shell: bash shell: bash
run: | run: |
rm -rf public/drawio rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/ cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF rm -rf public/drawio/WEB-INF

1
.gitignore vendored
View File

@@ -75,3 +75,4 @@ ai-models.json
# local backups # local backups
*.bak *.bak
.gstack/

View File

@@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Examples](#examples) - [Examples](#examples)
- [Features](#features) - [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview) - [MCP Server](#mcp-server)
- [Claude Code CLI](#claude-code-cli) - [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Try it Online](#try-it-online) - [Try it Online](#try-it-online)
@@ -63,24 +63,24 @@ Here are some example prompts and their generated diagrams:
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>GCP architecture diagram</strong><br /> <strong>RAG Technique Diagram</strong><br />
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" /> <img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>AWS architecture diagram</strong><br /> <strong>Authentication using React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p> <p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" /> <img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Azure architecture diagram</strong><br /> <strong>Open Innovation</strong><br />
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" /> <img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Cat sketch prompt</strong><br /> <strong>Cat sketch</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" /> <img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
</td> </td>
@@ -99,9 +99,7 @@ Here are some example prompts and their generated diagrams:
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure) - **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization - **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server (Preview) ## MCP Server
> **Preview Feature**: This feature is experimental and may not be stable.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).

View File

@@ -175,95 +175,109 @@ export default function AboutCN() {
</p> </p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* ResNet50 Architecture */}
<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">
Transformer连接器 ResNet50模型架构动画
</h3> </h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong>Prompt:</strong> Give me an{" "}
<strong></strong>Transformer架构图 <strong>animated</strong> architecture diagram
of the ResNet50 model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image <Image
src="/animated_connectors.svg" src="/resnet50.svg"
alt="带动画连接器的Transformer架构" alt="ResNet50模型架构"
width={480} width={480}
height={360} height={360}
className="mx-auto" className="mx-auto"
/> />
</div> </div>
</div>
{/* Cloud Architecture 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"> <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">
GCP架构 RAG技术
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong>Prompt:</strong> Generate a RAG
<strong>GCP图标</strong> architecture diagram for{" "}
GCP架构图 <strong>chat application</strong>. Use
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]">
<Image <Image
src="/gcp_demo.svg" src="/rag_prod.svg"
alt="GCP架构图" alt="RAG架构图"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
AWS架构图 React和AWS认证流程
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong>Prompt:</strong> Generate
<strong>AWS图标</strong> authentication process using React with{" "}
AWS架构图 <strong>AWS</strong>. Use Serverless
architecture.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/aws_demo.svg" src="/auth.svg"
alt="AWS架构图" alt="认证架构图"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
Azure架构图 Scrum流程
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong>Prompt:</strong> Generate agile
<strong>Azure图标</strong> scrum workflow diagram for software
Azure架构图 development team.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/azure_demo.svg" src="/agile_scrum.svg"
alt="Azure架构图" alt="敏捷Scrum流程图"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/cat_demo.svg" src="/inno.svg"
alt="猫咪绘图" alt="开放式创新图"
width={240} width={480}
height={240} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </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">

View File

@@ -168,96 +168,109 @@ export default function AboutJA() {
</p> </p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* ResNet50 Architecture */}
<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">
Transformerコネクタ ResNet50モデルアーキテクチャアニメーション
</h3> </h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Give me an{" "}
<strong></strong> <strong>animated</strong> architecture diagram
Transformerアーキテクチャ図を作成してください of the ResNet50 model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image <Image
src="/animated_connectors.svg" src="/resnet50.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ" alt="ResNet50モデルアーキテクチャ"
width={480} width={480}
height={360} height={360}
className="mx-auto" className="mx-auto"
/> />
</div> </div>
</div>
{/* Cloud Architecture 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"> <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">
GCPアーキテクチャ図 RAG技術ダイアグラム
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Generate a RAG
<strong>GCPアイコン</strong> architecture diagram for{" "}
使GCPアーキテクチャ図を生成してください <strong>chat application</strong>. Use
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]">
<Image <Image
src="/gcp_demo.svg" src="/rag_prod.svg"
alt="GCPアーキテクチャ図" alt="RAGアーキテクチャ図"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
AWSアーキテクチャ図 ReactとAWSによる認証
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Generate
<strong>AWSアイコン</strong> authentication process using React with{" "}
使AWSアーキテクチャ図を生成してください <strong>AWS</strong>. Use Serverless
architecture.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/aws_demo.svg" src="/auth.svg"
alt="AWSアーキテクチャ図" alt="認証アーキテクチャ図"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
Azureアーキテクチャ図
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Generate agile
<strong>Azureアイコン</strong> scrum workflow diagram for software
使Azureアーキテクチャ図を生成してください development team.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/azure_demo.svg" src="/agile_scrum.svg"
alt="Azureアーキテクチャ図" alt="アジャイルスクラム図"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/cat_demo.svg" src="/inno.svg"
alt="猫の絵" alt="オープンイノベーション図"
width={240} width={480}
height={240} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </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">

View File

@@ -182,99 +182,109 @@ export default function About() {
</p> </p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* ResNet50 Architecture */}
<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 Transformer Connectors Animated ResNet50 Model Architecture
</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> Give me an{" "}
<strong>animated connector</strong> diagram of <strong>animated</strong> architecture diagram
transformer&apos;s architecture. of the ResNet50 model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image <Image
src="/animated_connectors.svg" src="/resnet50.svg"
alt="Transformer Architecture with Animated Connectors" alt="Architecture diagram for ResNet50 model"
width={480} width={480}
height={360} height={360}
className="mx-auto" className="mx-auto"
/> />
</div> </div>
</div>
{/* Cloud Architecture 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"> <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">
GCP Architecture Diagram RAG Technique Diagram
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a GCP <strong>Prompt:</strong> Generate a RAG
architecture diagram with{" "} architecture diagram for{" "}
<strong>GCP icons</strong>. Users connect to <strong>chat application</strong>. Use
a frontend hosted on an instance. 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]">
<Image <Image
src="/gcp_demo.svg" src="/rag_prod.svg"
alt="GCP Architecture Diagram" alt="RAG Architecture Diagram"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
AWS Architecture Diagram Authentication using React and AWS
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an AWS <strong>Prompt:</strong> Generate
architecture diagram with{" "} authentication process using React with{" "}
<strong>AWS icons</strong>. Users connect to <strong>AWS</strong>. Use Serverless
a frontend hosted on an instance. architecture.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/aws_demo.svg" src="/auth.svg"
alt="AWS Architecture Diagram" alt="Authentication Architecture Diagram"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
Azure Architecture Diagram Agile Scrum Process
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an Azure <strong>Prompt:</strong> Generate agile
architecture diagram with{" "} scrum workflow diagram for software
<strong>Azure icons</strong>. Users connect development team.
to a frontend hosted on an instance.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/azure_demo.svg" src="/agile_scrum.svg"
alt="Azure Architecture Diagram" alt="Agile Scrum Diagram"
width={400} width={480}
height={300} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div>
<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">
Cat Sketch Open Innovation
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Draw a cute cat for <strong>Prompt:</strong> Create
me. visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/cat_demo.svg" src="/inno.svg"
alt="Cat Drawing" alt="Open Innovation Diagram"
width={240} width={480}
height={240} height={360}
className="mx-auto" className="max-w-full max-h-full object-contain"
/> />
</div> </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">

View File

@@ -10,8 +10,8 @@ import {
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() { export default function Home() {
const { const {
@@ -27,13 +27,11 @@ export default function Home() {
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false) const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState( const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net", process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
) )
@@ -56,7 +54,7 @@ export default function Home() {
} }
const savedUi = localStorage.getItem("drawio-theme") const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") { if (isDrawioTheme(savedUi)) {
setDrawioUi(savedUi) setDrawioUi(savedUi)
} }
@@ -84,11 +82,6 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`) setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
} }
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -97,17 +90,6 @@ export default function Home() {
onDrawioLoad() onDrawioLoad()
}, [onDrawioLoad]) }, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
// Only suppress modified state when persistence is available
if (canPersist) {
drawioRef.current?.status({ message: "", modified: false })
}
},
[canPersist, drawioRef, handleDiagramAutoSave],
)
const handleDarkModeChange = () => { const handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
@@ -117,10 +99,9 @@ export default function Home() {
resetDrawioReady() resetDrawioReady()
} }
const handleDrawioUiChange = () => { const handleDrawioUiChange = (theme: DrawioTheme) => {
const newUi = drawioUi === "min" ? "sketch" : "min" localStorage.setItem("drawio-theme", theme)
localStorage.setItem("drawio-theme", newUi) setDrawioUi(theme)
setDrawioUi(newUi)
setIsDrawioReady(false) setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
@@ -191,7 +172,7 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && canPersistChecked && ( {isLoaded && (
<div <div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
@@ -199,28 +180,19 @@ export default function Home() {
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`} key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef} ref={drawioRef}
autosave autosave
onAutoSave={handleDrawioAutoSave} onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: false,
libraries: false, libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false, saveAndExit: false,
noSaveBtn: true, noSaveBtn: true,
noExitBtn: true, noExitBtn: true,
dark: darkMode, dark:
darkMode || drawioUi === "dark",
lang: currentLang, lang: currentLang,
// Enable offline mode in Electron to disable external service calls // Enable offline mode in Electron to disable external service calls
...(isElectron && { ...(isElectron && {
@@ -268,7 +240,7 @@ export default function Home() {
isVisible={isChatVisible} isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange} onDrawioUiChange={handleDrawioUiChange}
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange} onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile} isMobile={isMobile}

View File

@@ -436,7 +436,18 @@ ${userInputText}
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn // - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// Some providers (e.g. MiniMax) don't support multiple system messages // Some providers (e.g. MiniMax) don't support multiple system messages
// Merge them into a single system message for compatibility // Merge them into a single system message for compatibility
const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) // Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint =
resolvedProvider === "openai" &&
!!(
baseUrl ||
process.env.OPENAI_BASE_URL ||
(serverModelConfig.baseUrlEnv &&
process.env[serverModelConfig.baseUrlEnv])
)
const isSingleSystemProvider =
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
const xmlContext = `${ const xmlContext = `${
previousXml previousXml

View File

@@ -1,7 +1,7 @@
import { extract } from "@extractus/article-extractor" import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import TurndownService from "turndown" import TurndownService from "turndown"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
@@ -28,8 +28,10 @@ export async function POST(req: Request) {
) )
} }
// SSRF protection // SSRF protection: parse-url has no use case for fetching internal
if (!allowPrivateUrls && isPrivateUrl(url)) { // hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (isPrivateUrl(url)) {
return NextResponse.json( return NextResponse.json(
{ error: "Cannot access private/internal URLs" }, { error: "Cannot access private/internal URLs" },
{ status: 400 }, { status: 400 },

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -141,9 +141,6 @@ export default function ExamplePanel({
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors"> <span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer} {dict.examples.mcpServer}
</span> </span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription} {dict.examples.mcpDescription}

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { import {
BookmarkPlus,
Download, Download,
History, History,
Image as ImageIcon, Image as ImageIcon,
@@ -19,6 +20,7 @@ import {
} from "react" } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast" import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog" import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector" import { ModelSelector } from "@/components/model-selector"
@@ -236,6 +238,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
const [showHistory, setShowHistory] = useState(false) const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false) const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false) const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter") const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
@@ -439,6 +442,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
return ( return (
<form <form
id="chat-form"
onSubmit={onSubmit} onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${ className={`w-full transition-all duration-200 ${
isDragging isDragging
@@ -538,6 +542,18 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
</ButtonWithTooltip> </ButtonWithTooltip>
)} )}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -610,6 +626,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
isExtracting={isExtractingUrl} isExtracting={isExtractingUrl}
/> />
)} )}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => setShowSaveAsTemplate(false)}
initialPrompt={input.trim()}
/>
</form> </form>
) )
}, },

View File

@@ -3,6 +3,7 @@
import type { UIMessage } from "ai" import type { UIMessage } from "ai"
import { import {
BookmarkPlus,
Check, Check,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@@ -26,6 +27,7 @@ import {
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { ChatLobby } from "@/components/chat/ChatLobby" import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ToolCallCard } from "@/components/chat/ToolCallCard" import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard" import type { ValidationState } from "@/components/chat/ValidationCard"
@@ -159,6 +161,10 @@ interface ChatMessageDisplayProps {
loadedMessageIdsRef?: MutableRefObject<Set<string>> loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState> validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void onImproveWithSuggestions?: (feedback: string) => void
onSendTemplate?: (
template: import("@/lib/template-storage").Template,
) => void
currentInput?: string
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -178,6 +184,8 @@ export function ChatMessageDisplay({
loadedMessageIdsRef, loadedMessageIdsRef,
validationStates = {}, validationStates = {},
onImproveWithSuggestions, onImproveWithSuggestions,
onSendTemplate,
currentInput = "",
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -237,6 +245,10 @@ export function ChatMessageDisplay({
const [expandedPdfSections, setExpandedPdfSections] = useState< const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean> Record<string, boolean>
>({}) >({})
// Track "Save as Template" dialog
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
string | null
>(null)
const setCopyState = ( const setCopyState = (
messageId: string, messageId: string,
@@ -405,6 +417,7 @@ export function ChatMessageDisplay({
// Track previous message count to detect bulk loads vs streaming // Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0) const prevMessageCountRef = useRef(0)
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current && messages.length > 0) { if (messagesEndRef.current && messages.length > 0) {
@@ -418,8 +431,17 @@ export function ChatMessageDisplay({
return return
} }
// Single message added - smooth scroll // Throttle scroll during streaming to avoid layout thrashing
// Leading + trailing: scroll immediately, then once more after cooldown
if (!scrollThrottleRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
scrollThrottleRef.current = setTimeout(() => {
scrollThrottleRef.current = null
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
})
}, 150)
}
} }
}, [messages]) }, [messages])
@@ -650,6 +672,8 @@ export function ChatMessageDisplay({
onDeleteSession={onDeleteSession} onDeleteSession={onDeleteSession}
setInput={setInput} setInput={setInput}
setFiles={setFiles} setFiles={setFiles}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
dict={dict} dict={dict}
/> />
) : messages.length === 0 ? null : ( ) : messages.length === 0 ? null : (
@@ -747,8 +771,42 @@ export function ChatMessageDisplay({
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</button> </button>
{/* Save as Template button - only for user messages */}
<button
type="button"
onClick={() =>
setSaveAsTemplateMessageId(
message.id,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={
dict.templates
?.saveAsTemplate ||
"Save as Template"
}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
</div> </div>
)} )}
{/* Save as Template Dialog */}
{saveAsTemplateMessageId === message.id && (
<TemplateCreateDialog
open={true}
onOpenChange={(open) => {
if (!open)
setSaveAsTemplateMessageId(null)
}}
onSuccess={() => {
setSaveAsTemplateMessageId(null)
}}
initialPrompt={getUserOriginalText(
message,
)}
/>
)}
<div className="max-w-[85%] min-w-0"> <div className="max-w-[85%] min-w-0">
{/* Reasoning blocks - displayed first for assistant messages */} {/* Reasoning blocks - displayed first for assistant messages */}
{message.role === "assistant" && {message.role === "assistant" &&

View File

@@ -32,6 +32,7 @@ import { useSessionManager } 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 { DrawioTheme } from "@/lib/drawio-themes"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage" import { sanitizeMessages } from "@/lib/session-storage"
@@ -68,8 +69,8 @@ interface ChatMessage {
interface ChatPanelProps { interface ChatPanelProps {
isVisible: boolean isVisible: boolean
onToggleVisibility: () => void onToggleVisibility: () => void
drawioUi: "min" | "sketch" drawioUi: DrawioTheme
onToggleDrawioUi: () => void onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
isMobile?: boolean isMobile?: boolean
@@ -110,7 +111,7 @@ export default function ChatPanel({
isVisible, isVisible,
onToggleVisibility, onToggleVisibility,
drawioUi, drawioUi,
onToggleDrawioUi, onDrawioUiChange,
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
isMobile = false, isMobile = false,
@@ -973,6 +974,25 @@ export default function ChatPanel({
pathname, pathname,
]) ])
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
flushSync(() => {
setInput(template.prompt)
setFiles([])
setUrlData(new Map())
})
const formElement = document.getElementById(
"chat-form",
) as HTMLFormElement | null
if (formElement) {
formElement.requestSubmit()
}
},
[setInput, setFiles, setUrlData],
)
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
@@ -1376,6 +1396,8 @@ export default function ChatPanel({
loadedMessageIdsRef={loadedMessageIdsRef} loadedMessageIdsRef={loadedMessageIdsRef}
validationStates={validationStates} validationStates={validationStates}
onImproveWithSuggestions={handleImproveWithSuggestions} onImproveWithSuggestions={handleImproveWithSuggestions}
onSendTemplate={handleSendTemplate}
currentInput={input}
/> />
</main> </main>
@@ -1421,7 +1443,7 @@ export default function ChatPanel({
open={showSettingsDialog} open={showSettingsDialog}
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi} onDrawioUiChange={onDrawioUiChange}
darkMode={darkMode} darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode} onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle} minimalStyle={minimalStyle}

View File

@@ -8,7 +8,8 @@ import {
Trash2, Trash2,
X, X,
} from "lucide-react" } from "lucide-react"
import { useState } from "react" import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import ExamplePanel from "@/components/chat-example-panel" import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath" import Image from "@/components/image-with-basepath"
import { import {
@@ -21,6 +22,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
interface SessionMetadata { interface SessionMetadata {
id: string id: string
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
onDeleteSession?: (id: string) => void onDeleteSession?: (id: string) => void
setInput: (input: string) => void setInput: (input: string) => void
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: { dict: {
sessionHistory?: { sessionHistory?: {
recentChats?: string recentChats?: string
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
deleteTitle?: string deleteTitle?: string
deleteDescription?: string deleteDescription?: string
} }
templates?: {
title?: string
myTemplates?: string
}
examples?: { examples?: {
quickExamples?: string quickExamples?: string
} }
@@ -75,33 +84,73 @@ function formatSessionDate(
}) })
} }
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({ export function ChatLobby({
sessions, sessions,
onSelectSession, onSelectSession,
onDeleteSession, onDeleteSession,
setInput, setInput,
setFiles, setFiles,
onSendTemplate,
currentInput = "",
dict, dict,
}: ChatLobbyProps) { }: ChatLobbyProps) {
// Track whether examples section is expanded (collapsed by default when there's history) const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(false) const [examplesExpanded, setExamplesExpanded] = useState(true)
// Delete confirmation dialog state const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null) const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
// Search filter for history
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0 const hasHistory = sessions.length > 0
if (!hasHistory) { if (!hasHistory) {
// Show full examples when no history if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return <ExamplePanel setInput={setInput} setFiles={setFiles} /> return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
} }
// Show history + collapsible examples when there are sessions // Show history + collapsible examples when there are sessions
return ( return (
<div className="py-6 px-2 animate-fade-in"> <div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */} {/* Recent Chats Section */}
{panelVisibility.recentChats && (
<div className="mb-6"> <div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"} {dict.sessionHistory?.recentChats || "Recent Chats"}
@@ -145,7 +194,10 @@ export function ChatLobby({
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left" className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)} onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault() e.preventDefault()
onSelectSession(session.id) onSelectSession(session.id)
} }
@@ -206,8 +258,39 @@ export function ChatLobby({
)} )}
</div> </div>
</div> </div>
)}
{/* Collapsible Examples Section */} {/* Collapsible My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
<div className="border-t border-border/50 pt-4"> <div className="border-t border-border/50 pt-4">
<button <button
type="button" type="button"
@@ -233,6 +316,7 @@ export function ChatLobby({
</div> </div>
)} )}
</div> </div>
)}
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<AlertDialog <AlertDialog

View File

@@ -0,0 +1,203 @@
"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()
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>
)
}

View File

@@ -0,0 +1,215 @@
"use client"
import { Bookmark, Edit2 } 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 { type Template, updateTemplate } from "@/lib/template-storage"
interface TemplateEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void
}
export function TemplateEditDialog({
open,
onOpenChange,
template,
onSuccess,
}: TemplateEditDialogProps) {
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)
// Populate form when template changes
useEffect(() => {
if (template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!template) return
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined,
pinned,
}
const updated = await updateTemplate(template.id, updates)
if (updated) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.updateFailed)
}
} catch (err) {
console.error("Failed to update template:", err)
setError(dict.templates.updateFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit2 className="w-5 h-5" />
{dict.templates.editTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.editDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label
htmlFor="edit-prompt"
className="text-foreground"
>
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="edit-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="edit-title"
className="text-foreground"
>
{dict.templates.titleLabel}
</Label>
<Input
id="edit-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="edit-description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="edit-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="edit-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="edit-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.common.save}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,622 @@
"use client"
import {
Bookmark,
Copy,
Download,
Edit2,
FileText,
Plus,
Search,
Trash2,
Upload,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary"
import {
deleteTemplate,
duplicateTemplate,
exportTemplates,
getAllTemplates,
importTemplates,
incrementClickCount,
incrementRunCount,
searchTemplates,
type Template,
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
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({
setInput,
onSendTemplate,
currentInput = "",
}: TemplatePanelProps) {
const dict = useDictionary()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
null,
)
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMessage, setImportMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
const loadTemplates = useCallback(async () => {
const result = await getAllTemplates()
setTemplates(result)
setLoading(false)
}, [])
// Filter templates by search query
const filteredTemplates = searchQuery.trim()
? searchTemplates(templates, searchQuery)
: templates
useEffect(() => {
loadTemplates()
}, [loadTemplates])
const handleCreateSuccess = () => {
loadTemplates()
}
const handleEditSuccess = () => {
loadTemplates()
}
const handleEdit = (template: Template) => {
setTemplateToEdit(template)
setEditDialogOpen(true)
}
const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate(
template.id,
dict.templates.copySuffix || "(copy)",
)
if (duplicated) {
loadTemplates()
}
}
const handleDeleteClick = (template: Template) => {
setTemplateToDelete(template)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
if (!templateToDelete) return
const success = await deleteTemplate(templateToDelete.id)
if (success) {
loadTemplates()
}
setDeleteDialogOpen(false)
setTemplateToDelete(null)
}
const handleTogglePin = async (template: Template) => {
const updated = await updateTemplate(template.id, {
pinned: !template.pinned,
})
if (updated) {
loadTemplates()
}
}
// Handle template card click - send directly or show confirmation
const handleTemplateClick = async (template: Template) => {
// If there's unsent content in the input, show confirmation dialog
if (currentInput.trim()) {
setTemplateToSend(template)
setConfirmSendDialogOpen(true)
return
}
// No unsent content, send directly
await sendTemplate(template)
}
// Actually send the template
const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (onSendTemplate) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
// Call the send callback
onSendTemplate(template)
} else {
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Handle confirmation dialog - user confirmed to send template
const handleConfirmSend = async () => {
if (!templateToSend) return
await sendTemplate(templateToSend)
}
// Handle cancel - close dialog without sending
const handleCancelSend = () => {
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Export templates to JSON file
const handleExport = () => {
if (templates.length === 0) {
setImportMessage({
type: "error",
text: dict.templates.exportEmpty || "No templates to export",
})
return
}
try {
const exportData = exportTemplates(templates)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setImportMessage({
type: "success",
text: dict.templates.exportSuccess.replace(
"{count}",
String(templates.length),
),
})
setTimeout(() => setImportMessage(null), 3000)
} catch (error) {
console.error("Failed to export templates:", error)
setImportMessage({
type: "error",
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
})
}
}
// Import templates from JSON file
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
setImportMessage({
type: "error",
text:
dict.templates.importNoFile || "Please select a JSON file",
})
return
}
try {
const text = await file.text()
const data = JSON.parse(text)
// Validate import data
const validation = validateImportData(data)
if (!validation.valid) {
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
validation.error || "Invalid data",
),
})
return
}
// Import templates with dedup-append strategy
const result = await importTemplates(data.templates, templates)
// Reload template list
await loadTemplates()
setImportMessage({
type: "success",
text: dict.templates.importSuccess
.replace("{imported}", String(result.imported))
.replace("{skipped}", String(result.skipped)),
})
setTimeout(() => setImportMessage(null), 5000)
} catch (error) {
console.error("Failed to import templates:", error)
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
error instanceof Error ? error.message : "Unknown error",
),
})
} finally {
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
<div className="py-6 px-2 animate-fade-in">
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{dict.templates.emptyTitle}
</p>
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
{dict.templates.emptyDescription}
</p>
<button
type="button"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
</div>
</div>
)
}
// Template list
return (
<div className="py-2 px-2 animate-fade-in">
<div className="space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={dict.templates.searchPlaceholder}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{dict.templates.createButton}
</button>
<div className="flex-1" />
<button
type="button"
onClick={handleExport}
disabled={templates.length === 0}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={dict.templates.exportTemplates}
>
<Download className="w-3.5 h-3.5" />
{dict.templates.exportTemplates}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={dict.templates.importTemplates}
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
</div>
{/* Import message */}
{importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)}
<div className="space-y-2">
{loading
? // Loading skeleton
Array.from({ length: 3 }).map((_, i) => (
<div
key={`skeleton-${String(i)}`}
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</div>
))
: filteredTemplates.length === 0
? // Search empty state
!loading && (
<div className="flex flex-col items-center justify-center py-6 px-4">
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground text-center">
{dict.templates.searchNoResults}
</p>
</div>
)
: filteredTemplates.map((template) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
<div
key={template.id}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() =>
handleTemplateClick(template)
}
onKeyDown={(e) => {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
handleTemplateClick(template)
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">
{template.title}
</div>
{template.pinned && (
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
)}
</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">
{template.description}
</div>
)}
</div>
{/* Actions and stats */}
<div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
: dict.templates.neverUsed}
</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">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleTogglePin(template)
}}
className={`p-1.5 rounded-lg transition-all ${
template.pinned
? "text-primary hover:text-primary/80 hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
title={
template.pinned
? dict.templates
.unpin || "Unpin"
: dict.templates.pin ||
"Pin"
}
>
<Bookmark
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleEdit(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={dict.common.edit}
>
<Edit2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDuplicate(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={
dict.templates.duplicate ||
"Duplicate"
}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
<TemplateEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
template={templateToEdit}
onSuccess={handleEditSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.deleteTitle ||
"Delete this template?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.deleteDescription ||
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirm Send Dialog - when there's unsent input */}
<AlertDialog
open={confirmSendDialogOpen}
onOpenChange={setConfirmSendDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.confirmSendTitle ||
"Replace current input?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.confirmSendDescription ||
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelSend}>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton ||
"Send Template"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -195,7 +195,22 @@ export function ToolCallCard({
{input && isExpanded && ( {input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20"> <div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" /> <CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" && ) : typeof input === "object" &&
input.operations && input.operations &&
Array.isArray(input.operations) ? ( Array.isArray(input.operations) ? (

View File

@@ -405,7 +405,7 @@ export function ModelConfigDialog({
</span> </span>
</div> </div>
<ScrollArea className="flex-1 px-2"> <ScrollArea className="flex-1 px-2 min-h-0">
<div className="space-y-1 pb-2"> <div className="space-y-1 pb-2">
{config.providers.length === 0 ? ( {config.providers.length === 0 ? (
<div className="px-3 py-8 text-center"> <div className="px-3 py-8 text-center">

View File

@@ -20,7 +20,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg" export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
interface SaveDialogProps { interface SaveDialogProps {
open: boolean open: boolean
@@ -74,6 +74,11 @@ export function SaveDialog({
label: dict.save.formats.svg, label: dict.save.formats.svg,
extension: ".svg", extension: ".svg",
}, },
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
extension: ".drawio.svg",
},
] ]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format) const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)

View File

@@ -2,7 +2,7 @@
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react" import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react" import { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -25,6 +25,7 @@ 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 { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
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"
@@ -63,8 +64,8 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
drawioUi: "min" | "sketch" drawioUi: DrawioTheme
onToggleDrawioUi: () => void onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean darkMode: boolean
onToggleDarkMode: () => void onToggleDarkMode: () => void
minimalStyle?: boolean minimalStyle?: boolean
@@ -90,7 +91,7 @@ function SettingsContent({
open, open,
onOpenChange, onOpenChange,
drawioUi, drawioUi,
onToggleDrawioUi, onDrawioUiChange,
darkMode, darkMode,
onToggleDarkMode, onToggleDarkMode,
minimalStyle = false, minimalStyle = false,
@@ -114,14 +115,31 @@ function SettingsContent({
const [currentLang, setCurrentLang] = useState("en") const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter") const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Panel visibility state
const [showRecentChats, setShowRecentChats] = useState(true)
const [showMyTemplates, setShowMyTemplates] = useState(true)
const [showQuickExamples, setShowQuickExamples] = useState(true)
const handlePanelToggle = useCallback(
(key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
localStorage.setItem(key, String(value))
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
},
[],
)
// Proxy settings state (Electron only) // Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("") const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("") const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false) const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => { useEffect(() => {
// Only fetch if not cached in localStorage // Re-fetch config whenever the dialog opens to ensure we always show
if (getStoredAccessCodeRequired() !== null) return // the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
fetch(getApiEndpoint("/api/config")) fetch(getApiEndpoint("/api/config"))
.then((res) => { .then((res) => {
@@ -137,10 +155,9 @@ function SettingsContent({
setAccessCodeRequired(required) setAccessCodeRequired(required)
}) })
.catch(() => { .catch(() => {
// Don't cache on error - allow retry on next mount // Keep existing cached value on error
setAccessCodeRequired(false)
}) })
}, []) }, [open])
// Detect current language from pathname // Detect current language from pathname
useEffect(() => { useEffect(() => {
@@ -164,6 +181,17 @@ function SettingsContent({
) )
setSendShortcut(storedSendShortcut || "ctrl-enter") setSendShortcut(storedSendShortcut || "ctrl-enter")
setShowRecentChats(
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
)
setShowMyTemplates(
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
)
setShowQuickExamples(
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
"false",
)
setError("") setError("")
// Load proxy settings (Electron only) // Load proxy settings (Electron only)
@@ -405,23 +433,40 @@ function SettingsContent({
{/* Draw.io Style */} {/* Draw.io Style */}
<SettingItem <SettingItem
label={dict.settings.drawioStyle} label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${ description={dict.settings.drawioStyleDescription}
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
> >
<Button <Select
id="drawio-ui" value={drawioUi}
variant="outline" onValueChange={(v) =>
onClick={onToggleDrawioUi} onDrawioUiChange(v as DrawioTheme)
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal" }
> >
{dict.settings.switchTo}{" "} <SelectTrigger
{drawioUi === "min" id="drawio-ui-select"
? dict.settings.sketch aria-label={dict.settings.drawioStyle}
: dict.settings.minimal} className="w-[120px] h-9 rounded-xl"
</Button> >
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
</SettingItem> </SettingItem>
{/* Diagram Style */} {/* Diagram Style */}
@@ -443,6 +488,63 @@ function SettingsContent({
</div> </div>
</SettingItem> </SettingItem>
{/* Panel Visibility */}
<SettingItem
label={dict.settings.panelVisibility}
description={dict.settings.panelVisibilityDescription}
>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-recent-chats"
checked={showRecentChats}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showRecentChats,
v,
setShowRecentChats,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showRecentChats}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-my-templates"
checked={showMyTemplates}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showMyTemplates,
v,
setShowMyTemplates,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showMyTemplates}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-quick-examples"
checked={showQuickExamples}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showQuickExamples,
v,
setShowQuickExamples,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showQuickExamples}
</span>
</label>
</div>
</SettingItem>
{/* VLM Diagram Validation */} {/* VLM Diagram Validation */}
<SettingItem <SettingItem
label={dict.settings.diagramValidation} label={dict.settings.diagramValidation}

View File

@@ -65,6 +65,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
if (hasCalledOnLoadRef.current) return if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true hasCalledOnLoadRef.current = true
setIsDrawioReady(true) setIsDrawioReady(true)
// Restore diagram after remount (e.g., theme/UI change)
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
drawioRef.current.load({ xml: chartXMLRef.current })
}
} }
const resetDrawioReady = () => { const resetDrawioReady = () => {
@@ -77,24 +81,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
chartXMLRef.current = chartXML chartXMLRef.current = chartXML
}, [chartXML]) }, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
const lastRestoredXmlRef = useRef<string>("")
useEffect(() => {
if (!isDrawioReady || !drawioRef.current) return
// Only load if we have a real diagram and it's different from what we already loaded
if (
isRealDiagram(chartXML) &&
chartXML !== lastRestoredXmlRef.current
) {
lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML })
} else if (!isRealDiagram(chartXML)) {
// Reset when diagram is cleared so a future restore can re-load the same XML.
lastRestoredXmlRef.current = ""
}
}, [isDrawioReady, chartXML])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string) => void) | null
@@ -233,7 +219,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
saveResolverRef.current = { resolver: null, format: null } saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail // For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute // Only drawio (which uses xmlsvg internally) has the content attribute
if (format === "png" || format === "svg") { // xmlsvg is saved directly as SVG file, no need for extraction
if (format === "png" || format === "svg" || format === "xmlsvg") {
return return
} }
} }
@@ -296,7 +283,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
// Map format to draw.io export format // Map format to draw.io export format
const drawioFormat = format === "drawio" ? "xmlsvg" : format const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Set up the resolver before triggering export // Set up the resolver before triggering export
saveResolverRef.current = { saveResolverRef.current = {
@@ -320,8 +308,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = exportData fileContent = exportData
mimeType = "image/png" mimeType = "image/png"
extension = ".png" extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else { } else {
// SVG format // SVG format (view-only)
fileContent = exportData fileContent = exportData
mimeType = "image/svg+xml" mimeType = "image/svg+xml"
extension = ".svg" extension = ".svg"

View File

@@ -30,7 +30,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录) - [目录](#目录)
- [示例](#示例) - [示例](#示例)
- [功能特性](#功能特性) - [功能特性](#功能特性)
- [MCP服务器(预览)](#mcp服务器预览) - [MCP服务器](#mcp服务器)
- [Claude Code CLI](#claude-code-cli) - [Claude Code CLI](#claude-code-cli)
- [快速开始](#快速开始) - [快速开始](#快速开始)
- [在线试用](#在线试用) - [在线试用](#在线试用)
@@ -56,31 +56,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br /> <strong>动画Transformer连接器</strong><br />
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p> <p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" /> <img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>GCP架构图</strong><br /> <strong>RAG技术图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" /> <img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>AWS架构图</strong><br /> <strong>React和AWS认证流程</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" /> <img src="../../public/auth.svg" alt="认证架构图" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Azure架构图</strong><br /> <strong>开放式创新</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中用户连接到托管在实例上的前端。</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" /> <img src="../../public/inno.svg" alt="开放式创新图" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫咪素描</strong><br /> <strong>猫咪素描</strong><br />
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" /> <img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td> </td>
</tr> </tr>
@@ -98,9 +98,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **云架构图支持**专门支持生成云架构图AWS、GCP、Azure - **云架构图支持**专门支持生成云架构图AWS、GCP、Azure
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果 - **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器(预览) ## MCP服务器
> **预览功能**:此功能为实验性功能,可能不稳定。
通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。 通过MCP模型上下文协议在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。

View File

@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次) - [目次](#目次)
- [](#例) - [](#例)
- [機能](#機能) - [機能](#機能)
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー) - [MCPサーバー](#mcpサーバー)
- [Claude Code CLI](#claude-code-cli) - [Claude Code CLI](#claude-code-cli)
- [はじめに](#はじめに) - [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す) - [オンラインで試す](#オンラインで試す)
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br /> <strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p> <p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" /> <img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>GCPアーキテクチャ図</strong><br /> <strong>RAG技術ダイアグラム</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" /> <img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>AWSアーキテクチャ図</strong><br /> <strong>ReactとAWSによる認証</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" /> <img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Azureアーキテクチャ図</strong><br /> <strong>オープンイノベーション</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" /> <img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫のスケッチ</strong><br /> <strong>猫のスケッチ</strong><br />
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" /> <img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
</td> </td>
</tr> </tr>
@@ -96,9 +96,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure - **クラウドアーキテクチャダイアグラムサポート**クラウドアーキテクチャダイアグラムの生成を専門的にサポートAWS、GCP、Azure
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成 - **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー(プレビュー) ## MCPサーバー
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。 MCPModel Context Protocolを介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。

View File

@@ -95,6 +95,10 @@ linux:
arch: arch:
- x64 - x64
- arm64 - arm64
- target: rpm
arch:
- x64
- arm64
# Publish configuration (optional) # Publish configuration (optional)
publish: publish:

View File

@@ -101,8 +101,8 @@ declare global {
} }
export type { export type {
ConfigPreset,
ApplyPresetResult, ApplyPresetResult,
ConfigPreset,
ProxyConfig, ProxyConfig,
SetProxyResult, SetProxyResult,
SetUserLocaleResult, SetUserLocaleResult,

View File

@@ -60,6 +60,13 @@ export function createWindow(serverUrl: string): BrowserWindow {
mainWindow.webContents.openDevTools() mainWindow.webContents.openDevTools()
} }
// Override the draw.io iframe's beforeunload handler so the window can
// close after the user edits text in a shape (fixes #815). Diagrams are
// already persisted via autosave, so the prompt is unnecessary.
mainWindow.webContents.on("will-prevent-unload", (event) => {
event.preventDefault()
})
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
mainWindow = null mainWindow = null
}) })

View File

@@ -1262,7 +1262,6 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
case "glm": case "glm":
case "qwen": case "qwen":
case "qiniu": case "qiniu":
case "kimi":
case "novita": { case "novita": {
const envVar = PROVIDER_ENV_VARS[provider] const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) { if (!envVar) {
@@ -1288,6 +1287,23 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "kimi": {
const apiKey = resolveApiKey(overrides, "KIMI_API_KEY")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO["kimi"]?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same
// reasoning_content field as DeepSeek, so this provider correctly
// captures and replays reasoning in multi-turn conversations.
const customProvider = createDeepSeek({ apiKey, baseURL })
model = customProvider(modelId)
break
}
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
@@ -1356,12 +1372,13 @@ export function supportsImageInput(modelId: string): boolean {
} }
// Qwen text models (not vision variants like qwen-vl) // Qwen text models (not vision variants like qwen-vl)
// qwen3.5-plus is a vision model // Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
if ( if (
lowerModelId.includes("qwen") && lowerModelId.includes("qwen") &&
!hasVisionIndicator && !hasVisionIndicator &&
!lowerModelId.includes("qwen3.5-plus") && !lowerModelId.includes("qwen3.5") &&
!lowerModelId.includes("qwen3.5-flash") !lowerModelId.includes("qvq")
) { ) {
return false return false
} }

17
lib/drawio-themes.ts Normal file
View File

@@ -0,0 +1,17 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}

View File

@@ -76,8 +76,7 @@
"creativeDescription": "Draw something fun and creative", "creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples are cached for instant response", "cachedNote": "Examples are cached for instant response",
"mcpServer": "MCP Server", "mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor", "mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
"preview": "PREVIEW"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@@ -103,10 +102,12 @@
"theme": "Theme", "theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.", "themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style", "drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style:", "drawioStyleDescription": "Canvas style",
"switchTo": "Switch to", "themeDefault": "Default",
"minimal": "Minimal", "themeDark": "Dark",
"sketch": "Sketch", "themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"diagramStyle": "Diagram Style", "diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.", "diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut", "sendShortcut": "Send Shortcut",
@@ -129,7 +130,12 @@
"disabled": "Disabled", "disabled": "Disabled",
"customSystemMessage": "Custom System Message", "customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.", "customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams..." "customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
"showMyTemplates": "My Templates",
"showQuickExamples": "Quick Examples"
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -140,7 +146,8 @@
"formats": { "formats": {
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG Image", "png": "PNG Image",
"svg": "SVG Image" "svg": "SVG Image",
"xmlsvg": "Editable SVG"
}, },
"savedSuccessfully": "Saved successfully!" "savedSuccessfully": "Saved successfully!"
}, },
@@ -265,6 +272,52 @@
"searchPlaceholder": "Search chats...", "searchPlaceholder": "Search chats...",
"noResults": "No chats found" "noResults": "No chats found"
}, },
"templates": {
"title": "My Templates",
"subtitle": "Your personal prompt library for quick diagram creation",
"emptyTitle": "No templates yet",
"emptyDescription": "Create your first template to start building your personal prompt library",
"createFirst": "Create First Template",
"neverUsed": "Not used yet",
"usedCount": "{count} uses",
"myTemplates": "My Templates",
"createTitle": "Create Template",
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
"promptLabel": "Prompt",
"promptPlaceholder": "Describe the diagram you want to create...",
"promptRequired": "Prompt is required",
"titleLabel": "Title",
"titlePlaceholder": "Enter a title",
"titleHint": "Leave empty to use the first 20 characters of prompt",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Add a description for this template",
"pinnedLabel": "Pin Template",
"pinnedHint": "Pinned templates appear at the top of the list",
"createButton": "Create Template",
"createFailed": "Failed to create template. Please try again.",
"editTitle": "Edit Template",
"editDescription": "Update your template content and settings.",
"updateFailed": "Failed to update template. Please try again.",
"duplicate": "Duplicate",
"copySuffix": "(copy)",
"deleteTitle": "Delete this template?",
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
"confirmSendTitle": "Replace current input?",
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
"confirmSendButton": "Send Template",
"searchPlaceholder": "Search templates...",
"searchNoResults": "No templates match your search",
"pin": "Pin to top",
"unpin": "Unpin from top",
"saveAsTemplate": "Save as Template",
"exportTemplates": "Export Templates",
"importTemplates": "Import Templates",
"exportEmpty": "No templates to export",
"exportSuccess": "Exported {count} template(s) successfully",
"importNoFile": "Please select a JSON file",
"importFailed": "Import failed: {error}",
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
},
"validation": { "validation": {
"title": "Validate Diagram", "title": "Validate Diagram",
"capturing": "Capturing", "capturing": "Capturing",

View File

@@ -76,8 +76,7 @@
"creativeDescription": "楽しくてクリエイティブなものを描く", "creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例はキャッシュされ、即座に応答します", "cachedNote": "例はキャッシュされ、即座に応答します",
"mcpServer": "MCP サーバー", "mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用", "mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
"preview": "プレビュー"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
@@ -103,10 +102,12 @@
"theme": "テーマ", "theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。", "themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル", "drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル", "drawioStyleDescription": "キャンバススタイル",
"switchTo": "切り替え", "themeDefault": "デフォルト",
"minimal": "ミニマル", "themeDark": "ダーク",
"sketch": "スケッチ", "themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"diagramStyle": "ダイアグラムスタイル", "diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。", "diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット", "sendShortcut": "送信ショートカット",
@@ -129,7 +130,12 @@
"disabled": "無効", "disabled": "無効",
"customSystemMessage": "カスタムシステムメッセージ", "customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。", "customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..." "customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
"showMyTemplates": "マイテンプレート",
"showQuickExamples": "クイック例"
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -140,7 +146,8 @@
"formats": { "formats": {
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 画像", "png": "PNG 画像",
"svg": "SVG 画像" "svg": "SVG 画像",
"xmlsvg": "編集可能 SVG"
}, },
"savedSuccessfully": "保存完了!" "savedSuccessfully": "保存完了!"
}, },
@@ -350,5 +357,51 @@
"allModelsShown": "すべてのモデルを表示(未検証を含む)", "allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません", "unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル" "serverDefaultModel": "サーバーデフォルトモデル"
},
"templates": {
"title": "マイテンプレート",
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
"emptyTitle": "テンプレートがありません",
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
"createFirst": "最初のテンプレートを作成",
"neverUsed": "未使用",
"usedCount": "{count} 回使用",
"myTemplates": "マイテンプレート",
"createTitle": "テンプレート作成",
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
"promptLabel": "プロンプト",
"promptPlaceholder": "作成するダイアグラムを説明してください...",
"promptRequired": "プロンプトは必須です",
"titleLabel": "タイトル",
"titlePlaceholder": "タイトルを入力",
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
"descriptionLabel": "説明",
"descriptionPlaceholder": "このテンプレートの説明を追加",
"pinnedLabel": "テンプレートをピン留め",
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
"createButton": "テンプレート作成",
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
"editTitle": "テンプレート編集",
"editDescription": "テンプレートの内容と設定を更新します。",
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
"duplicate": "複製",
"copySuffix": "(コピー)",
"deleteTitle": "このテンプレートを削除しますか?",
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
"confirmSendTitle": "現在の入力を置き換えますか?",
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
"confirmSendButton": "テンプレートを送信",
"searchPlaceholder": "テンプレートを検索...",
"searchNoResults": "検索に一致するテンプレートがありません",
"pin": "上部にピン留め",
"unpin": "ピン留め解除",
"saveAsTemplate": "テンプレートとして保存",
"exportTemplates": "テンプレートをエクスポート",
"importTemplates": "テンプレートをインポート",
"exportEmpty": "エクスポートするテンプレートがありません",
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
} }
} }

View File

@@ -76,8 +76,7 @@
"creativeDescription": "繪製有趣且富有創意的內容", "creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例已快取,可即時回應", "cachedNote": "範例已快取,可即時回應",
"mcpServer": "MCP 伺服器", "mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用", "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
"preview": "預覽"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
@@ -103,10 +102,12 @@
"theme": "主題", "theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。", "themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式", "drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式", "drawioStyleDescription": "畫布樣式",
"switchTo": "切換到", "themeDefault": "預設",
"minimal": "簡約", "themeDark": "深色",
"sketch": "草圖", "themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"diagramStyle": "圖表樣式", "diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。", "diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵", "sendShortcut": "傳送快捷鍵",
@@ -129,7 +130,12 @@
"disabled": "已停用", "disabled": "已停用",
"customSystemMessage": "自訂系統訊息", "customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。", "customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..." "customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的範本",
"showQuickExamples": "快速範例"
}, },
"save": { "save": {
"title": "儲存圖表", "title": "儲存圖表",
@@ -140,7 +146,8 @@
"formats": { "formats": {
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 圖片", "png": "PNG 圖片",
"svg": "SVG 圖片" "svg": "SVG 圖片",
"xmlsvg": "可編輯 SVG"
}, },
"savedSuccessfully": "儲存成功!" "savedSuccessfully": "儲存成功!"
}, },
@@ -265,6 +272,52 @@
"searchPlaceholder": "搜尋對話...", "searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話" "noResults": "未找到對話"
}, },
"templates": {
"title": "我的範本",
"subtitle": "快速建立圖表的個人提示庫",
"emptyTitle": "尚無範本",
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
"createFirst": "建立第一個範本",
"neverUsed": "未使用過",
"usedCount": "使用 {count} 次",
"myTemplates": "我的範本",
"createTitle": "建立範本",
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
"promptLabel": "提示",
"promptPlaceholder": "描述您想建立的圖表...",
"promptRequired": "提示為必填項",
"titleLabel": "標題",
"titlePlaceholder": "輸入標題",
"titleHint": "留空則使用提示的前 20 個字元",
"descriptionLabel": "描述",
"descriptionPlaceholder": "為此範本新增描述",
"pinnedLabel": "釘選範本",
"pinnedHint": "釘選的範本會顯示在清單頂部",
"createButton": "建立範本",
"createFailed": "建立範本失敗,請重試。",
"editTitle": "編輯範本",
"editDescription": "更新範本內容和設定。",
"updateFailed": "更新範本失敗,請重試。",
"duplicate": "複製",
"copySuffix": "(副本)",
"deleteTitle": "刪除此範本?",
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
"confirmSendTitle": "取代目前輸入?",
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
"confirmSendButton": "傳送範本",
"searchPlaceholder": "搜尋範本...",
"searchNoResults": "沒有符合的範本",
"pin": "置頂",
"unpin": "取消置頂",
"saveAsTemplate": "儲存為範本",
"exportTemplates": "匯出範本",
"importTemplates": "匯入範本",
"exportEmpty": "沒有可匯出的範本",
"exportSuccess": "成功匯出 {count} 個範本",
"importNoFile": "請選擇一個 JSON 檔案",
"importFailed": "匯入失敗:{error}",
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
},
"validation": { "validation": {
"title": "驗證圖表", "title": "驗證圖表",
"capturing": "截圖中", "capturing": "截圖中",

View File

@@ -76,8 +76,7 @@
"creativeDescription": "绘制有趣且富有创意的内容", "creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例已缓存,可即时响应", "cachedNote": "示例已缓存,可即时响应",
"mcpServer": "MCP 服务器", "mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用", "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
"preview": "预览"
}, },
"settings": { "settings": {
"title": "设置", "title": "设置",
@@ -103,10 +102,12 @@
"theme": "主题", "theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。", "themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式", "drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式", "drawioStyleDescription": "画布样式",
"switchTo": "切换到", "themeDefault": "默认",
"minimal": "简约", "themeDark": "深色",
"sketch": "草图", "themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"diagramStyle": "图表样式", "diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。", "diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键", "sendShortcut": "发送快捷键",
@@ -129,7 +130,12 @@
"disabled": "已禁用", "disabled": "已禁用",
"customSystemMessage": "自定义系统消息", "customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。", "customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..." "customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
"showMyTemplates": "我的模板",
"showQuickExamples": "快速示例"
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -140,7 +146,8 @@
"formats": { "formats": {
"drawio": "Draw.io XML", "drawio": "Draw.io XML",
"png": "PNG 图片", "png": "PNG 图片",
"svg": "SVG 图片" "svg": "SVG 图片",
"xmlsvg": "可编辑 SVG"
}, },
"savedSuccessfully": "保存成功!" "savedSuccessfully": "保存成功!"
}, },
@@ -265,6 +272,52 @@
"searchPlaceholder": "搜索对话...", "searchPlaceholder": "搜索对话...",
"noResults": "未找到对话" "noResults": "未找到对话"
}, },
"templates": {
"title": "我的模板库",
"subtitle": "您的个人 prompt 库,快速创建图表",
"emptyTitle": "暂无模板",
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
"createFirst": "创建第一个模板",
"neverUsed": "未使用过",
"usedCount": "使用 {count} 次",
"myTemplates": "我的模板",
"createTitle": "创建模板",
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
"promptLabel": "提示词",
"promptPlaceholder": "描述您想要创建的图表...",
"promptRequired": "提示词不能为空",
"titleLabel": "标题",
"titlePlaceholder": "输入标题",
"titleHint": "留空则使用提示词前 20 个字符作为标题",
"descriptionLabel": "描述",
"descriptionPlaceholder": "为此模板添加描述",
"pinnedLabel": "置顶模板",
"pinnedHint": "置顶的模板会显示在列表顶部",
"createButton": "创建模板",
"createFailed": "创建模板失败,请重试。",
"editTitle": "编辑模板",
"editDescription": "更新模板内容和设置。",
"updateFailed": "更新模板失败,请重试。",
"duplicate": "复制",
"copySuffix": "(副本)",
"deleteTitle": "删除此模板?",
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
"confirmSendTitle": "替换当前输入?",
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
"confirmSendButton": "发送模板",
"searchPlaceholder": "搜索模板...",
"searchNoResults": "没有匹配的模板",
"pin": "置顶",
"unpin": "取消置顶",
"saveAsTemplate": "保存为模板",
"exportTemplates": "导出模板",
"importTemplates": "导入模板",
"exportEmpty": "没有可导出的模板",
"exportSuccess": "成功导出 {count} 个模板",
"importNoFile": "请选择一个 JSON 文件",
"importFailed": "导入失败:{error}",
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
},
"validation": { "validation": {
"title": "验证图表", "title": "验证图表",
"capturing": "截图中", "capturing": "截图中",

View File

@@ -1,9 +1,10 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb" import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import type { Template } from "./template-storage"
// Constants // Constants
const DB_NAME = "next-ai-drawio" const DB_NAME = "next-ai-drawio"
const DB_VERSION = 1 const DB_VERSION = 2
const STORE_NAME = "sessions" const STORE_NAME = "sessions"
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb" const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
const MAX_SESSIONS = 50 const MAX_SESSIONS = 50
@@ -43,37 +44,20 @@ interface ChatSessionDB extends DBSchema {
value: ChatSession value: ChatSession
indexes: { "by-updated": number } indexes: { "by-updated": number }
} }
templates: {
key: string
value: Template
indexes: {
"by-updated": number
"by-pinned": number
"by-run-count": number
"by-last-used": number
}
}
} }
// Database singleton // Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
@@ -85,25 +69,26 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}) })
store.createIndex("by-updated", "updatedAt") store.createIndex("by-updated", "updatedAt")
} }
// Future migrations: if (oldVersion < 2) { ... } // Version 2: templates store (added by template-storage.ts)
// Note: We also need to include this here to ensure the upgrade
// callback properly handles all migrations when opening from this file
if (oldVersion < 2) {
// Check if templates store already exists (created by template-storage.ts)
if (!db.objectStoreNames.contains("templates")) {
const templateStore = db.createObjectStore(
"templates",
{
keyPath: "id",
}, },
terminated() { )
resetDBPromise() templateStore.createIndex("by-updated", "updatedAt")
templateStore.createIndex("by-pinned", "pinned")
templateStore.createIndex("by-run-count", "runCount")
templateStore.createIndex("by-last-used", "lastUsedAt")
}
}
}, },
}) })
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
} }
return dbPromise return dbPromise
} }
@@ -118,24 +103,11 @@ export function isIndexedDBAvailable(): boolean {
} }
} }
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations // CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []
try { try {
return await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly") const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = [] const metadata: SessionMetadata[] = []
@@ -150,14 +122,12 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
createdAt: s.createdAt, createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
messageCount: s.messages.length, messageCount: s.messages.length,
hasDiagram: hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
!!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl, thumbnailDataUrl: s.thumbnailDataUrl,
}) })
cursor = await cursor.continue() cursor = await cursor.continue()
} }
return metadata return metadata
})
} catch (error) { } catch (error) {
console.error("Failed to get session metadata:", error) console.error("Failed to get session metadata:", error)
return [] return []
@@ -167,9 +137,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> { export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null if (!isIndexedDBAvailable()) return null
try { try {
return await withDB(async (db) => { const db = await getDB()
return (await db.get(STORE_NAME, id)) || null return (await db.get(STORE_NAME, id)) || null
})
} catch (error) { } catch (error) {
console.error("Failed to get session:", error) console.error("Failed to get session:", error)
return null return null
@@ -179,9 +148,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
export async function saveSession(session: ChatSession): Promise<boolean> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (error) { } catch (error) {
// Handle quota exceeded // Handle quota exceeded
@@ -193,9 +161,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await deleteOldestSession() await deleteOldestSession()
// Retry once // Retry once
try { try {
await withDB(async (db) => { const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
})
return true return true
} catch (retryError) { } catch (retryError) {
console.error( console.error(
@@ -214,9 +181,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
export async function deleteSession(id: string): Promise<void> { export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
await db.delete(STORE_NAME, id) await db.delete(STORE_NAME, id)
})
} catch (error) { } catch (error) {
console.error("Failed to delete session:", error) console.error("Failed to delete session:", error)
} }
@@ -225,9 +191,8 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> { export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0 if (!isIndexedDBAvailable()) return 0
try { try {
return await withDB(async (db) => { const db = await getDB()
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
})
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return 0 return 0
@@ -237,7 +202,7 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> { export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return if (!isIndexedDBAvailable()) return
try { try {
await withDB(async (db) => { const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite") const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated") const index = tx.store.index("by-updated")
const cursor = await index.openCursor() const cursor = await index.openCursor()
@@ -245,7 +210,6 @@ export async function deleteOldestSession(): Promise<void> {
await cursor.delete() await cursor.delete()
} }
await tx.done await tx.done
})
} catch (error) { } catch (error) {
console.error("Failed to delete oldest session:", error) console.error("Failed to delete oldest session:", error)
} }

View File

@@ -9,7 +9,9 @@
export function isPrivateUrl(urlString: string): boolean { export function isPrivateUrl(urlString: string): boolean {
try { try {
const url = new URL(urlString) const url = new URL(urlString)
const hostname = url.hostname.toLowerCase() // Strip a trailing dot so FQDN forms like "localhost." (which still
// resolve to 127.0.0.1) cannot bypass the equality checks below.
const hostname = url.hostname.toLowerCase().replace(/\.$/, "")
// Block localhost // Block localhost
if ( if (

View File

@@ -30,4 +30,9 @@ export const STORAGE_KEYS = {
// Custom system message // Custom system message
customSystemMessage: "next-ai-draw-io-custom-system-message", customSystemMessage: "next-ai-draw-io-custom-system-message",
// Panel visibility
showRecentChats: "next-ai-draw-io-show-recent-chats",
showMyTemplates: "next-ai-draw-io-show-my-templates",
showQuickExamples: "next-ai-draw-io-show-quick-examples",
} as const } as const

385
lib/template-storage.ts Normal file
View File

@@ -0,0 +1,385 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
// Constants
const DB_NAME = "next-ai-drawio-templates"
const DB_VERSION = 1
const STORE_NAME = "templates"
// Types
export interface Template {
id: string
title: string
prompt: string
description?: string
createdAt: number
updatedAt: number
clickCount: number
runCount: number
lastUsedAt: number
pinned: boolean
}
export type TemplateCreateInput = Pick<Template, "prompt"> &
Partial<
Omit<
Template,
| "id"
| "createdAt"
| "updatedAt"
| "clickCount"
| "runCount"
| "lastUsedAt"
>
>
interface TemplateDB extends DBSchema {
templates: {
key: string
value: Template
indexes: {
"by-updated": number
"by-pinned": number
"by-run-count": number
"by-last-used": number
}
}
}
// Default title: first 20 chars of trimmed prompt, with ellipsis if truncated
const DEFAULT_TITLE_MAX_LENGTH = 20
export function generateDefaultTitle(prompt: string): string {
const trimmed = prompt.trim()
if (trimmed.length <= DEFAULT_TITLE_MAX_LENGTH) return trimmed
return trimmed.slice(0, DEFAULT_TITLE_MAX_LENGTH).trim() + "..."
}
// Database singleton
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
if (!dbPromise) {
dbPromise = openDB<TemplateDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
const templateStore = db.createObjectStore(STORE_NAME, {
keyPath: "id",
})
templateStore.createIndex("by-updated", "updatedAt")
templateStore.createIndex("by-pinned", "pinned")
templateStore.createIndex("by-run-count", "runCount")
templateStore.createIndex("by-last-used", "lastUsedAt")
}
}
},
})
}
return dbPromise
}
// Check if IndexedDB is available
export function isIndexedDBAvailable(): boolean {
if (typeof window === "undefined") return false
try {
return "indexedDB" in window && window.indexedDB !== null
} catch {
return false
}
}
// CRUD Operations
export async function getAllTemplates(): Promise<Template[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const templates = await db.getAll(STORE_NAME)
return sortTemplates(templates)
} catch (error) {
console.error("Failed to get templates:", error)
return []
}
}
export async function getTemplate(id: string): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
} catch (error) {
console.error("Failed to get template:", error)
return null
}
}
export async function createTemplate(
input: TemplateCreateInput,
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
const prompt = input.prompt.trim()
if (!prompt) return null
const now = Date.now()
const template: Template = {
id: nanoid(),
title: input.title?.trim() || generateDefaultTitle(prompt),
prompt,
description: input.description?.trim() || undefined,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: input.pinned ?? false,
}
try {
const db = await getDB()
await db.put(STORE_NAME, template)
return template
} catch (error) {
console.error("Failed to create template:", error)
return null
}
}
export async function updateTemplate(
id: string,
updates: Partial<Omit<Template, "id" | "createdAt">>,
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const updated: Template = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
}
await db.put(STORE_NAME, updated)
return updated
} catch (error) {
console.error("Failed to update template:", error)
return null
}
}
export async function deleteTemplate(id: string): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
return true
} catch (error) {
console.error("Failed to delete template:", error)
return false
}
}
export async function duplicateTemplate(
id: string,
copySuffix = "(copy)",
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const now = Date.now()
const duplicate: Template = {
...existing,
id: nanoid(),
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
}
await db.put(STORE_NAME, duplicate)
return duplicate
} catch (error) {
console.error("Failed to duplicate template:", error)
return null
}
}
// Usage tracking
export async function incrementClickCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
} catch (error) {
console.error("Failed to increment click count:", error)
}
}
export async function incrementRunCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
const now = Date.now()
template.runCount += 1
template.lastUsedAt = now
template.updatedAt = now
await db.put(STORE_NAME, template)
} catch (error) {
console.error("Failed to increment run count:", error)
}
}
// Search
export function searchTemplates(
templates: Template[],
query: string,
): Template[] {
if (!query.trim()) return templates
const lowerQuery = query.toLowerCase()
return templates.filter((t) => {
const titleMatch = t.title.toLowerCase().includes(lowerQuery)
const descMatch =
t.description?.toLowerCase().includes(lowerQuery) ?? false
return titleMatch || descMatch
})
}
// Sorting
export function sortTemplates(templates: Template[]): Template[] {
return [...templates].sort((a, b) => {
// pinned desc
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
// runCount desc
if (a.runCount !== b.runCount) return b.runCount - a.runCount
// lastUsedAt desc
if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt
// updatedAt desc
return b.updatedAt - a.updatedAt
})
}
// Import / Export
export const TEMPLATE_EXPORT_SCHEMA_VERSION = 1
export interface TemplateExportData {
schemaVersion: number
exportedAt: number
templates: Template[]
}
export function exportTemplates(templates: Template[]): TemplateExportData {
return {
schemaVersion: TEMPLATE_EXPORT_SCHEMA_VERSION,
exportedAt: Date.now(),
templates,
}
}
export function validateImportData(data: unknown): {
valid: boolean
error?: string
} {
if (!data || typeof data !== "object") {
return { valid: false, error: "Invalid data: expected an object" }
}
const obj = data as Record<string, unknown>
if (typeof obj.schemaVersion !== "number") {
return { valid: false, error: "Missing or invalid schemaVersion" }
}
if (!Array.isArray(obj.templates)) {
return { valid: false, error: "Missing or invalid templates array" }
}
for (let i = 0; i < obj.templates.length; i++) {
const t = obj.templates[i]
if (!t || typeof t !== "object") {
return {
valid: false,
error: `Template at index ${i} is not an object`,
}
}
const template = t as Record<string, unknown>
if (typeof template.prompt !== "string" || !template.prompt.trim()) {
return {
valid: false,
error: `Template at index ${i} has missing or empty prompt`,
}
}
if (typeof template.title !== "string" || !template.title.trim()) {
return {
valid: false,
error: `Template at index ${i} has missing or empty title`,
}
}
}
return { valid: true }
}
export async function importTemplates(
templates: Template[],
existingTemplates: Template[],
): Promise<{ imported: number; skipped: number }> {
let imported = 0
let skipped = 0
const existingKeys = new Set(
existingTemplates.map((t) => `${t.title}|||${t.prompt}`),
)
for (const t of templates) {
const key = `${t.title}|||${t.prompt}`
if (existingKeys.has(key)) {
skipped++
continue
}
const now = Date.now()
const newTemplate: Template = {
id: nanoid(),
title:
String(t.title || "").trim() ||
generateDefaultTitle(String(t.prompt || "")),
prompt: String(t.prompt || "").trim(),
description: t.description ? String(t.description) : undefined,
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
updatedAt: now,
clickCount: typeof t.clickCount === "number" ? t.clickCount : 0,
runCount: typeof t.runCount === "number" ? t.runCount : 0,
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
}
try {
const db = await getDB()
await db.put(STORE_NAME, newTemplate)
existingKeys.add(key)
imported++
} catch (error) {
console.error("Failed to import template:", error)
}
}
return { imported, skipped }
}

3656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.14", "version": "0.4.16",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -49,9 +49,9 @@
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "^1.17.1", "@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^2.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0", "@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -72,12 +72,12 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"idb": "^8.0.3", "idb": "^8.0.3",
"jsonrepair": "^3.13.1", "jsonrepair": "^3.13.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.577.0",
"motion": "^12.23.25", "motion": "^12.23.25",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^3.0.0",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",
@@ -108,7 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.4.4", "@biomejs/biome": "2.4.13",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -127,8 +127,8 @@
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.27.2", "esbuild": "^0.28.0",
"eslint": "9.39.3", "eslint": "9.39.4",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.17", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.17", "version": "0.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
@@ -481,9 +481,9 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9", "@hono/node-server": "^1.19.9",
@@ -521,9 +521,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.11.0", "version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2062,9 +2062,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.17", "version": "0.2.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -4,6 +4,29 @@
*/ */
import http from "node:http" import http from "node:http"
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
function readBody(
req: http.IncomingMessage,
res: http.ServerResponse,
cb: (body: string) => void,
): void {
let body = ""
let size = 0
req.on("data", (chunk: Buffer) => {
size += chunk.length
if (size > MAX_BODY_BYTES) {
res.writeHead(413, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Payload too large" }))
req.destroy()
return
}
body += chunk
})
req.on("end", () => cb(body))
}
import { import {
addHistory, addHistory,
clearHistory, clearHistory,
@@ -155,7 +178,7 @@ export function startHttpServer(port = 6002): Promise<number> {
} }
}) })
server.listen(port, () => { server.listen(port, "127.0.0.1", () => {
serverPort = port serverPort = port
log.info(`HTTP server running on http://localhost:${port}`) log.info(`HTTP server running on http://localhost:${port}`)
resolve(port) resolve(port)
@@ -266,11 +289,7 @@ function handleStateApi(
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const data = JSON.parse(body) const data = JSON.parse(body)
const { sessionId } = data const { sessionId } = data
@@ -347,11 +366,7 @@ function handleRestoreApi(
return return
} }
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const { sessionId, index } = JSON.parse(body) const { sessionId, index } = JSON.parse(body)
if (!sessionId || index === undefined) { if (!sessionId || index === undefined) {
@@ -393,11 +408,7 @@ function handleHistorySvgApi(
return return
} }
let body = "" readBody(req, res, (body) => {
req.on("data", (chunk) => {
body += chunk
})
req.on("end", () => {
try { try {
const { sessionId, svg } = JSON.parse(body) const { sessionId, svg } = JSON.parse(body)
if (!sessionId || !svg) { if (!sessionId || !svg) {

View File

@@ -155,19 +155,22 @@ server.registerTool(
server.registerTool( server.registerTool(
"create_new_diagram", "create_new_diagram",
{ {
description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely. description: `Create a NEW diagram from mxGraphModel XML. ONLY use this when creating a diagram from scratch.
⚠️ DO NOT use this tool to modify an existing diagram — it will DESTROY all existing content and user changes. Use edit_diagram instead for ANY modifications to an existing diagram.
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml. CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
When to use this tool: When to use this tool:
- Creating a new diagram from scratch - Creating a new diagram from scratch (no existing diagram)
- Replacing the current diagram with a completely different one - The user explicitly asks to "start over" or "create a new diagram"
- Major structural changes that require regenerating the diagram
When to use edit_diagram instead: When to use edit_diagram instead (ALWAYS prefer edit_diagram if a diagram already exists):
- Small modifications to existing diagram - ANY modifications to an existing diagram
- Adding/removing individual elements - Adding/removing/moving elements
- Changing labels, colors, or positions - Changing labels, colors, styles, or positions
- Restructuring or reorganizing existing content
- Adding new elements to an existing diagram
XML FORMAT - Full mxGraphModel structure: XML FORMAT - Full mxGraphModel structure:
<mxGraphModel> <mxGraphModel>

View File

@@ -12,7 +12,8 @@
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true "resolveJsonModule": true,
"types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

1
public/agile_scrum.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

1
public/auth.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

1
public/inno.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

1
public/rag_prod.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

1
public/resnet50.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -35,8 +35,10 @@ test.describe("Iframe Interaction", () => {
await expect( await expect(
frame frame
.locator('text="Diagram"') .locator('text="Diagram"')
.or(frame.locator('[title*="Diagram"]')), .or(frame.locator('[title*="Diagram"]'))
).toBeVisible({ timeout: 10000 }) .filter({ visible: true })
.first(),
).toBeVisible({ timeout: 30000 })
}) })
test("diagram XML is rendered in iframe after generation", async ({ test("diagram XML is rendered in iframe after generation", async ({

View File

@@ -200,12 +200,21 @@ describe("supportsImageInput", () => {
it("returns true for Qwen vision models", () => { it("returns true for Qwen vision models", () => {
expect(supportsImageInput("qwen-vl")).toBe(true) expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("Qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5-plus")).toBe(true) expect(supportsImageInput("qwen3.5-plus")).toBe(true)
expect(supportsImageInput("qwen3.5-flash")).toBe(true) expect(supportsImageInput("qwen3.5-flash")).toBe(true)
expect(supportsImageInput("qwen3-vl-plus")).toBe(true) expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
expect(supportsImageInput("qwen3-vl-flash")).toBe(true) expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
}) })
it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => {
expect(supportsImageInput("qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qvq-max")).toBe(true)
expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qwen/qvq-max")).toBe(true)
})
it("returns false for GLM text models", () => { it("returns false for GLM text models", () => {
expect(supportsImageInput("glm-4")).toBe(false) expect(supportsImageInput("glm-4")).toBe(false)
expect(supportsImageInput("glm-4-plus")).toBe(false) expect(supportsImageInput("glm-4-plus")).toBe(false)
@@ -236,6 +245,67 @@ vi.mock("ollama-ai-provider-v2", () => {
return { createOllama: mockCreateOllama, ollama: mockOllama } return { createOllama: mockCreateOllama, ollama: mockOllama }
}) })
vi.mock("@ai-sdk/deepseek", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateDeepSeek = vi.fn(() => mockProviderFn)
const mockDeepseek = vi.fn(() => mockModel)
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
})
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
let createDeepSeekMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.KIMI_API_KEY = process.env.KIMI_API_KEY
savedEnv.KIMI_BASE_URL = process.env.KIMI_BASE_URL
delete process.env.KIMI_BASE_URL
const mod = await import("@ai-sdk/deepseek")
createDeepSeekMock = mod.createDeepSeek as ReturnType<typeof vi.fn>
createDeepSeekMock.mockClear()
})
afterEach(() => {
process.env.KIMI_API_KEY = savedEnv.KIMI_API_KEY
process.env.KIMI_BASE_URL = savedEnv.KIMI_BASE_URL
})
it("uses createDeepSeek with Kimi default base URL for reasoning_content support", () => {
process.env.KIMI_API_KEY = "test-kimi-key"
getAIModel({
provider: "kimi",
apiKey: "test-kimi-key",
modelId: "moonshot-v1-8k",
})
expect(createDeepSeekMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.moonshot.cn/v1",
}),
)
})
it("uses custom base URL when provided for kimi provider", () => {
process.env.KIMI_API_KEY = "test-kimi-key"
getAIModel({
provider: "kimi",
apiKey: "test-kimi-key",
baseUrl: "https://custom-kimi-endpoint.com/v1",
modelId: "kimi-k2.6",
})
expect(createDeepSeekMock).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://custom-kimi-endpoint.com/v1",
}),
)
})
})
describe("Ollama API key security", () => { describe("Ollama API key security", () => {
let createOllamaMock: ReturnType<typeof vi.fn> let createOllamaMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {} const savedEnv: Record<string, string | undefined> = {}

View File

@@ -0,0 +1,269 @@
import { describe, expect, it } from "vitest"
import {
generateDefaultTitle,
searchTemplates,
sortTemplates,
type Template,
validateImportData,
} from "@/lib/template-storage"
describe("generateDefaultTitle", () => {
it("returns the prompt as-is when 20 chars or fewer", () => {
expect(generateDefaultTitle("Hello")).toBe("Hello")
expect(generateDefaultTitle("A".repeat(20))).toBe("A".repeat(20))
})
it("truncates to 20 chars with ellipsis for longer prompts", () => {
const result = generateDefaultTitle(
"This is a very long prompt that should be truncated",
)
expect(result).toBe("This is a very long...")
expect(result.length).toBeGreaterThan(20)
})
it("trims whitespace before truncating", () => {
expect(generateDefaultTitle(" hello ")).toBe("hello")
expect(generateDefaultTitle(" " + "x".repeat(25) + " ")).toBe(
"xxxxxxxxxxxxxxxxxxxx...",
)
})
})
describe("sortTemplates", () => {
const baseTemplate = (
overrides: Partial<Omit<Template, "id">> & Pick<Template, "id">,
): Template => ({
title: "Test",
prompt: "test prompt",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
...overrides,
})
it("sorts pinned templates first", () => {
const templates = [
baseTemplate({ id: "1", runCount: 10 }),
baseTemplate({ id: "2", pinned: true, runCount: 1 }),
]
const result = sortTemplates(templates)
expect(result[0].id).toBe("2")
})
it("sorts by runCount descending among non-pinned", () => {
const templates = [
baseTemplate({ id: "1", runCount: 5 }),
baseTemplate({ id: "2", runCount: 10 }),
baseTemplate({ id: "3", runCount: 1 }),
]
const result = sortTemplates(templates)
expect(result.map((t) => t.id)).toEqual(["2", "1", "3"])
})
it("sorts by lastUsedAt descending when runCount is equal", () => {
const templates = [
baseTemplate({ id: "1", runCount: 5, lastUsedAt: 100 }),
baseTemplate({ id: "2", runCount: 5, lastUsedAt: 200 }),
]
const result = sortTemplates(templates)
expect(result[0].id).toBe("2")
})
it("sorts by updatedAt descending when runCount and lastUsedAt are equal", () => {
const templates = [
baseTemplate({
id: "1",
runCount: 5,
lastUsedAt: 100,
updatedAt: 50,
}),
baseTemplate({
id: "2",
runCount: 5,
lastUsedAt: 100,
updatedAt: 150,
}),
]
const result = sortTemplates(templates)
expect(result[0].id).toBe("2")
})
it("does not mutate the original array", () => {
const templates = [
baseTemplate({ id: "1", runCount: 1 }),
baseTemplate({ id: "2", runCount: 10 }),
]
const copy = [...templates]
sortTemplates(templates)
expect(templates.map((t) => t.id)).toEqual(copy.map((t) => t.id))
})
it("returns empty array for empty input", () => {
expect(sortTemplates([])).toEqual([])
})
})
describe("searchTemplates", () => {
const templates: Template[] = [
{
id: "1",
title: "Flowchart",
prompt: "Create a flowchart",
description: "Basic flowchart template",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
},
{
id: "2",
title: "ER Diagram",
prompt: "Design a database",
description: "Entity relationship diagram",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
},
{
id: "3",
title: "Network",
prompt: "Draw network topology",
createdAt: 1000,
updatedAt: 1000,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
},
]
it("returns all templates when query is empty", () => {
expect(searchTemplates(templates, "")).toHaveLength(3)
expect(searchTemplates(templates, " ")).toHaveLength(3)
})
it("searches by title", () => {
const result = searchTemplates(templates, "flowchart")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("1")
})
it("searches by description", () => {
const result = searchTemplates(templates, "entity")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("2")
})
it("is case-insensitive", () => {
const result = searchTemplates(templates, "FLOWCHART")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("1")
})
it("returns empty array when no match", () => {
const result = searchTemplates(templates, "nonexistent")
expect(result).toHaveLength(0)
})
it("handles templates without description", () => {
const result = searchTemplates(templates, "network")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("3")
})
})
describe("validateImportData", () => {
it("rejects non-object input", () => {
expect(validateImportData(null).valid).toBe(false)
expect(validateImportData("string").valid).toBe(false)
expect(validateImportData(123).valid).toBe(false)
})
it("rejects missing schemaVersion", () => {
const result = validateImportData({ templates: [] })
expect(result.valid).toBe(false)
expect(result.error).toContain("schemaVersion")
})
it("rejects missing templates array", () => {
const result = validateImportData({ schemaVersion: 1 })
expect(result.valid).toBe(false)
expect(result.error).toContain("templates")
})
it("rejects template with missing prompt", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [{ title: "Test" }],
})
expect(result.valid).toBe(false)
expect(result.error).toContain("prompt")
})
it("rejects template with empty prompt", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [{ title: "Test", prompt: " " }],
})
expect(result.valid).toBe(false)
expect(result.error).toContain("prompt")
})
it("rejects template with missing title", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [{ prompt: "test" }],
})
expect(result.valid).toBe(false)
expect(result.error).toContain("title")
})
it("accepts valid data", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [
{
id: "1",
title: "Test",
prompt: "test prompt",
createdAt: Date.now(),
updatedAt: Date.now(),
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
},
],
})
expect(result.valid).toBe(true)
})
it("accepts empty templates array", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [],
})
expect(result.valid).toBe(true)
})
it("reports index of invalid template", () => {
const result = validateImportData({
schemaVersion: 1,
templates: [
{ title: "Valid", prompt: "valid" },
{ title: "Also Valid", prompt: "also valid" },
{ title: "No Prompt" },
],
})
expect(result.valid).toBe(false)
expect(result.error).toContain("index 2")
})
})

View File

@@ -35,6 +35,7 @@
"packages", "packages",
"electron", "electron",
"electron-standalone", "electron-standalone",
"dist-electron" "dist-electron",
"release"
] ]
} }