mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
55 Commits
fix/mcp-se
...
fix/parse-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9656fc0cfe | ||
|
|
1115b2d2cd | ||
|
|
08afb6dd34 | ||
|
|
c703159e00 | ||
|
|
ee75408136 | ||
|
|
2ed1a64ae3 | ||
|
|
2f2d75961d | ||
|
|
5406778dd6 | ||
|
|
bb65a8c07a | ||
|
|
4e223b6237 | ||
|
|
c60e3930a3 | ||
|
|
5c8ae4d6d7 | ||
|
|
f965f3fa2e | ||
|
|
73eefc7aa6 | ||
|
|
a8d27088ef | ||
|
|
d4454beb9a | ||
|
|
171174378c | ||
|
|
eadc2c2629 | ||
|
|
c77af86011 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
c083782802 | ||
|
|
f19dc919e1 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad |
9
.github/CONTRIBUTING.md
vendored
9
.github/CONTRIBUTING.md
vendored
@@ -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:
|
||||||
|
|||||||
4
.github/workflows/electron-release.yml
vendored
4
.github/workflows/electron-release.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -75,3 +75,4 @@ ai-models.json
|
|||||||
|
|
||||||
# local backups
|
# local backups
|
||||||
*.bak
|
*.bak
|
||||||
|
.gstack/
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -175,92 +175,106 @@ 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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="带动画连接器的Transformer架构"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50模型架构图"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP架构图"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS架构图"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="认证架构图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure架构图"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="敏捷Scrum流程图"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫咪绘图"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="开放式创新图"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,93 +168,106 @@ 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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="ResNet50モデルアーキテクチャ図"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCPアーキテクチャ図"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAGアーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWSアーキテクチャ図"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="認証アーキテクチャ図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azureアーキテクチャ図"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="アジャイルスクラム図"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="猫の絵"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="オープンイノベーション図"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,96 +182,106 @@ 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's architecture.
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
src="/animated_connectors.svg"
|
<Image
|
||||||
alt="Transformer Architecture with Animated Connectors"
|
src="/resnet50.svg"
|
||||||
width={480}
|
alt="Architecture diagram for ResNet50 model"
|
||||||
height={360}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/gcp_demo.svg"
|
<Image
|
||||||
alt="GCP Architecture Diagram"
|
src="/rag_prod.svg"
|
||||||
width={400}
|
alt="RAG Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/aws_demo.svg"
|
<Image
|
||||||
alt="AWS Architecture Diagram"
|
src="/auth.svg"
|
||||||
width={400}
|
alt="Authentication Architecture Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/azure_demo.svg"
|
<Image
|
||||||
alt="Azure Architecture Diagram"
|
src="/agile_scrum.svg"
|
||||||
width={400}
|
alt="Agile Scrum Diagram"
|
||||||
height={300}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
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's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
<Image
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
src="/cat_demo.svg"
|
<Image
|
||||||
alt="Cat Drawing"
|
src="/inno.svg"
|
||||||
width={240}
|
alt="Open Innovation Diagram"
|
||||||
height={240}
|
width={480}
|
||||||
className="mx-auto"
|
height={360}
|
||||||
/>
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,18 +10,24 @@ 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"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
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)
|
||||||
@@ -48,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,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()
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,8 @@ export default function Home() {
|
|||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
|
autosave
|
||||||
|
onAutoSave={handleDiagramAutoSave}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
@@ -184,7 +191,8 @@ export default function Home() {
|
|||||||
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 && {
|
||||||
@@ -232,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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -345,11 +345,12 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLM, Qwen, Kimi, Qiniu - OpenAI compatible
|
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu": {
|
case "qiniu":
|
||||||
|
case "novita": {
|
||||||
const baseURL =
|
const baseURL =
|
||||||
baseUrl ||
|
baseUrl ||
|
||||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
// Leading + trailing: scroll immediately, then once more after cooldown
|
||||||
|
if (!scrollThrottleRef.current) {
|
||||||
|
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" &&
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -138,26 +139,22 @@ export default function ChatPanel({
|
|||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
if (resolverRef && "current" in resolverRef) {
|
resolverRef.current = resolve
|
||||||
resolverRef.current = resolve
|
|
||||||
}
|
|
||||||
if (saveToHistory) {
|
if (saveToHistory) {
|
||||||
onExport()
|
onExport()
|
||||||
} else {
|
} else {
|
||||||
handleExportWithoutHistory()
|
handleExportWithoutHistory()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) => {
|
||||||
setTimeout(
|
const currentResolver = resolverRef.current
|
||||||
() =>
|
setTimeout(() => {
|
||||||
reject(
|
if (resolverRef.current === currentResolver) {
|
||||||
new Error(
|
resolverRef.current = null
|
||||||
"Chart export timed out after 10 seconds",
|
}
|
||||||
),
|
reject(new Error("Chart export timed out after 10 seconds"))
|
||||||
),
|
}, 10000)
|
||||||
10000,
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,7 +602,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSession = sessionManager.currentSession
|
const currentSession = sessionManager.currentSession
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
if (currentSession) {
|
||||||
// Restore from session manager (IndexedDB)
|
// Restore from session manager (IndexedDB)
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(currentSession)
|
syncUIWithSession(currentSession)
|
||||||
@@ -642,7 +639,7 @@ export default function ChatPanel({
|
|||||||
lastSyncedSessionIdRef.current = newSessionId
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
// Sync UI with new session
|
// Sync UI with new session
|
||||||
if (newSession && newSession.messages.length > 0) {
|
if (newSession) {
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(newSession)
|
syncUIWithSession(newSession)
|
||||||
} else if (!newSession) {
|
} else if (!newSession) {
|
||||||
@@ -697,7 +694,7 @@ export default function ChatPanel({
|
|||||||
// Debounce: save after 1 second of no changes
|
// Debounce: save after 1 second of no changes
|
||||||
localStorageDebounceRef.current = setTimeout(async () => {
|
localStorageDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0 || hasDiagramNow) {
|
||||||
const sessionData = await buildSessionData({
|
const sessionData = await buildSessionData({
|
||||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||||
@@ -719,6 +716,7 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
chartXML,
|
||||||
messages,
|
messages,
|
||||||
status,
|
status,
|
||||||
sessionIsAvailable,
|
sessionIsAvailable,
|
||||||
@@ -749,7 +747,8 @@ export default function ChatPanel({
|
|||||||
const handleVisibilityChange = async () => {
|
const handleVisibilityChange = async () => {
|
||||||
if (
|
if (
|
||||||
document.visibilityState === "hidden" &&
|
document.visibilityState === "hidden" &&
|
||||||
messagesRef.current.length > 0
|
(messagesRef.current.length > 0 ||
|
||||||
|
isRealDiagram(chartXMLRef.current))
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Attempt to save session - browser may not wait for completion
|
// Attempt to save session - browser may not wait for completion
|
||||||
@@ -975,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>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1378,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>
|
||||||
|
|
||||||
@@ -1423,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}
|
||||||
|
|||||||
@@ -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,164 +84,239 @@ 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 */}
|
||||||
<div className="mb-6">
|
{panelVisibility.recentChats && (
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
<div className="mb-6">
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
</p>
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
{/* Search Bar */}
|
</p>
|
||||||
<div className="relative mb-3">
|
{/* Search Bar */}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="relative mb-3">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
type="text"
|
<input
|
||||||
placeholder={
|
type="text"
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
placeholder={
|
||||||
"Search chats..."
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
}
|
"Search chats..."
|
||||||
value={searchQuery}
|
}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
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"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
{searchQuery && (
|
/>
|
||||||
<button
|
{searchQuery && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSearchQuery("")}
|
type="button"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
onClick={() => setSearchQuery("")}
|
||||||
>
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
>
|
||||||
</button>
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{sessions
|
<div className="space-y-2">
|
||||||
.filter((session) =>
|
{sessions
|
||||||
session.title
|
.filter((session) =>
|
||||||
|
session.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((session) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()),
|
.includes(searchQuery.toLowerCase()),
|
||||||
)
|
).length === 0 &&
|
||||||
.map((session) => (
|
searchQuery && (
|
||||||
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
<div
|
{dict.sessionHistory?.noResults ||
|
||||||
key={session.id}
|
"No chats found"}
|
||||||
role="button"
|
</p>
|
||||||
tabIndex={0}
|
)}
|
||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
|
||||||
onClick={() => onSelectSession(session.id)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectSession(session.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{session.thumbnailDataUrl ? (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={session.thumbnailDataUrl}
|
|
||||||
alt=""
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
className="object-contain w-full h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<MessageSquare className="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">
|
|
||||||
{session.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatSessionDate(
|
|
||||||
session.updatedAt,
|
|
||||||
dict.sessionHistory,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{onDeleteSession && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setSessionToDelete(session.id)
|
|
||||||
setDeleteDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
title={dict.common.delete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sessions.filter((s) =>
|
|
||||||
s.title
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
).length === 0 &&
|
|
||||||
searchQuery && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
{dict.sessionHistory?.noResults ||
|
|
||||||
"No chats found"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Examples Section */}
|
|
||||||
<div className="border-t border-border/50 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{dict.examples?.quickExamples || "Quick Examples"}
|
|
||||||
</span>
|
|
||||||
{examplesExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{examplesExpanded && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<ExamplePanel
|
|
||||||
setInput={setInput}
|
|
||||||
setFiles={setFiles}
|
|
||||||
minimal
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible My Templates Section */}
|
||||||
|
{panelVisibility.myTemplates && (
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.templates?.myTemplates || "My Templates"}
|
||||||
|
</span>
|
||||||
|
{templatesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{templatesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible Quick Examples Section */}
|
||||||
|
{panelVisibility.quickExamples && (
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.examples?.quickExamples || "Quick Examples"}
|
||||||
|
</span>
|
||||||
|
{examplesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{examplesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ExamplePanel
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
minimal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
622
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
state === "input-streaming" ||
|
||||||
|
state === "input-available" ? (
|
||||||
|
<pre
|
||||||
|
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
"var(--font-mono), ui-monospace, monospace",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{input.xml}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
)
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
input.operations &&
|
input.operations &&
|
||||||
Array.isArray(input.operations) ? (
|
Array.isArray(input.operations) ? (
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -513,7 +513,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider Details (Right Panel) */}
|
{/* Provider Details (Right Panel) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin">
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function ModelSelector({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||||
<ModelSelectorEmpty>
|
<ModelSelectorEmpty>
|
||||||
{displayModels.length === 0 &&
|
{displayModels.length === 0 &&
|
||||||
models.length > 0
|
models.length > 0
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -279,7 +307,7 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
@@ -289,7 +317,7 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{/* API Keys & Models */}
|
{/* API Keys & Models */}
|
||||||
{onOpenModelConfig && (
|
{onOpenModelConfig && (
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ interface DiagramContextType {
|
|||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
const chartXMLRef = useRef<string>("")
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
@@ -66,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 = () => {
|
||||||
@@ -78,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)
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
|
||||||
if (!isDrawioReady) {
|
|
||||||
hasDiagramRestoredRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only restore once per ready cycle
|
|
||||||
if (hasDiagramRestoredRef.current) return
|
|
||||||
hasDiagramRestoredRef.current = true
|
|
||||||
|
|
||||||
// Restore diagram from ref if we have one
|
|
||||||
const xmlToRestore = chartXMLRef.current
|
|
||||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
|
||||||
drawioRef.current.load({ xml: xmlToRestore })
|
|
||||||
}
|
|
||||||
}, [isDrawioReady])
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -234,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +253,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||||
|
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||||
|
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChartXML(data.xml)
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -287,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 = {
|
||||||
@@ -311,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"
|
||||||
@@ -391,6 +393,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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サーバー
|
||||||
|
|
||||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ linux:
|
|||||||
arch:
|
arch:
|
||||||
- x64
|
- x64
|
||||||
- arm64
|
- arm64
|
||||||
|
- target: rpm
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
|
||||||
# Publish configuration (optional)
|
# Publish configuration (optional)
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
2
electron/electron.d.ts
vendored
2
electron/electron.d.ts
vendored
@@ -101,8 +101,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ConfigPreset,
|
|
||||||
ApplyPresetResult,
|
ApplyPresetResult,
|
||||||
|
ConfigPreset,
|
||||||
ProxyConfig,
|
ProxyConfig,
|
||||||
SetProxyResult,
|
SetProxyResult,
|
||||||
SetUserLocaleResult,
|
SetUserLocaleResult,
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ if (!gotTheLock) {
|
|||||||
if (
|
if (
|
||||||
url.includes("diagrams.net") ||
|
url.includes("diagrams.net") ||
|
||||||
url.includes("draw.io") ||
|
url.includes("draw.io") ||
|
||||||
url.startsWith("http://localhost")
|
url.startsWith("http://localhost") ||
|
||||||
|
url.startsWith("http://127.0.0.1")
|
||||||
) {
|
) {
|
||||||
return { action: "allow" }
|
return { action: "allow" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function startNextServer(): Promise<string> {
|
|||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "localhost",
|
HOSTNAME: "127.0.0.1",
|
||||||
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
|
||||||
NODE_USE_ENV_PROXY: "1",
|
NODE_USE_ENV_PROXY: "1",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { app } from "electron"
|
|||||||
const PORT_CONFIG = {
|
const PORT_CONFIG = {
|
||||||
// Development mode uses fixed port for hot reload compatibility
|
// Development mode uses fixed port for hot reload compatibility
|
||||||
development: 6002,
|
development: 6002,
|
||||||
// Production mode uses fixed port (61337) to preserve localStorage
|
// Legacy production port — tried first to preserve localStorage for existing users
|
||||||
// Falls back to sequential ports if unavailable
|
legacyProduction: 61337,
|
||||||
production: 61337,
|
// New production port below the ephemeral range (49152-65535)
|
||||||
|
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
|
||||||
|
production: 13370,
|
||||||
// Maximum attempts to find an available port (fallback)
|
// Maximum attempts to find an available port (fallback)
|
||||||
maxAttempts: 100,
|
maxAttempts: 100,
|
||||||
}
|
}
|
||||||
@@ -27,7 +29,10 @@ let allocatedPort: number | null = null
|
|||||||
export function isPortAvailable(port: number): Promise<boolean> {
|
export function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once("error", () => resolve(false))
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
console.warn(`Port ${port} unavailable: ${err.code}`)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -39,12 +44,12 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
|||||||
/**
|
/**
|
||||||
* Find an available port
|
* Find an available port
|
||||||
* - In development: uses fixed port (6002)
|
* - In development: uses fixed port (6002)
|
||||||
* - In production: uses fixed port (61337) to preserve localStorage
|
* - In production: uses fixed port (13370) to preserve localStorage
|
||||||
* - Falls back to sequential ports if preferred port is unavailable
|
* - Falls back to sequential ports if preferred port is unavailable
|
||||||
|
* - Last resort: lets the OS assign a port (port 0)
|
||||||
*
|
*
|
||||||
* @param reuseExisting If true, try to reuse the previously allocated port
|
* @param reuseExisting If true, try to reuse the previously allocated port
|
||||||
* @returns Promise<number> The available port
|
* @returns Promise<number> The available port
|
||||||
* @throws Error if no available port found after max attempts
|
|
||||||
*/
|
*/
|
||||||
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
@@ -64,7 +69,16 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try preferred port first
|
// In production, try legacy port first to preserve existing users' localStorage
|
||||||
|
if (!isDev) {
|
||||||
|
const legacyPort = PORT_CONFIG.legacyProduction
|
||||||
|
if (await isPortAvailable(legacyPort)) {
|
||||||
|
allocatedPort = legacyPort
|
||||||
|
return legacyPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try preferred port
|
||||||
if (await isPortAvailable(preferredPort)) {
|
if (await isPortAvailable(preferredPort)) {
|
||||||
allocatedPort = preferredPort
|
allocatedPort = preferredPort
|
||||||
return preferredPort
|
return preferredPort
|
||||||
@@ -84,9 +98,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
// Last resort: let the OS pick an available port
|
||||||
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
console.warn(
|
||||||
|
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
|
||||||
)
|
)
|
||||||
|
const osPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.once("listening", () => {
|
||||||
|
const addr = server.address()
|
||||||
|
const port = (addr as net.AddressInfo).port
|
||||||
|
server.close(() => resolve(port))
|
||||||
|
})
|
||||||
|
server.listen(0, "127.0.0.1")
|
||||||
|
})
|
||||||
|
allocatedPort = osPort
|
||||||
|
console.log(`OS assigned port: ${osPort}`)
|
||||||
|
return osPort
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,5 +141,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://localhost:${allocatedPort}`
|
return `http://127.0.0.1:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,24 @@ 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
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle page title updates
|
// Handle page title updates
|
||||||
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
mainWindow.webContents.on("page-title-updated", (event, title) => {
|
||||||
if (title && !title.includes("localhost")) {
|
if (
|
||||||
|
title &&
|
||||||
|
!title.includes("localhost") &&
|
||||||
|
!title.includes("127.0.0.1")
|
||||||
|
) {
|
||||||
mainWindow?.setTitle(title)
|
mainWindow?.setTitle(title)
|
||||||
} else {
|
} else {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -167,3 +167,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Get your API key from: https://www.qiniu.com/ai/models
|
# Get your API key from: https://www.qiniu.com/ai/models
|
||||||
# QINIU_API_KEY=your_qiniu_api_key
|
# QINIU_API_KEY=your_qiniu_api_key
|
||||||
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
|
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
|
||||||
|
|
||||||
|
# Novita AI Configuration (Optional)
|
||||||
|
# Get your API key from: https://novita.ai/dashboard/key
|
||||||
|
# NOVITA_API_KEY=your_novita_api_key
|
||||||
|
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
"qwen",
|
"qwen",
|
||||||
"kimi",
|
"kimi",
|
||||||
"qiniu",
|
"qiniu",
|
||||||
|
"novita",
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +99,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"qiniu",
|
"qiniu",
|
||||||
"kimi",
|
"kimi",
|
||||||
"minimax",
|
"minimax",
|
||||||
|
"novita",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -520,7 +522,8 @@ function buildProviderOptions(
|
|||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "qiniu": {
|
case "qiniu":
|
||||||
|
case "novita": {
|
||||||
// These providers don't have reasoning configs in AI SDK yet
|
// These providers don't have reasoning configs in AI SDK yet
|
||||||
// Gateway passes through to underlying providers which handle their own configs
|
// Gateway passes through to underlying providers which handle their own configs
|
||||||
break
|
break
|
||||||
@@ -555,6 +558,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
qiniu: "QINIU_API_KEY",
|
qiniu: "QINIU_API_KEY",
|
||||||
kimi: "KIMI_API_KEY",
|
kimi: "KIMI_API_KEY",
|
||||||
minimax: "MINIMAX_API_KEY",
|
minimax: "MINIMAX_API_KEY",
|
||||||
|
novita: "NOVITA_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1258,7 +1262,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "kimi": {
|
case "novita": {
|
||||||
const envVar = PROVIDER_ENV_VARS[provider]
|
const envVar = PROVIDER_ENV_VARS[provider]
|
||||||
if (!envVar) {
|
if (!envVar) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1283,9 +1287,26 @@ 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`,
|
`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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,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
17
lib/drawio-themes.ts
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} 件の重複をスキップしました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "截圖中",
|
||||||
|
|||||||
@@ -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": "截图中",
|
||||||
|
|||||||
@@ -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,6 +44,16 @@ 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
|
||||||
@@ -58,7 +69,24 @@ 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
templateStore.createIndex("by-updated", "updatedAt")
|
||||||
|
templateStore.createIndex("by-pinned", "pinned")
|
||||||
|
templateStore.createIndex("by-run-count", "runCount")
|
||||||
|
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
385
lib/template-storage.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export type ProviderName =
|
|||||||
| "qiniu"
|
| "qiniu"
|
||||||
| "kimi"
|
| "kimi"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
|
| "novita"
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -101,6 +102,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
modelscope: "modelscope",
|
modelscope: "modelscope",
|
||||||
minimax: "minimax",
|
minimax: "minimax",
|
||||||
|
novita: "novita",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
@@ -179,6 +181,10 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "MiniMax",
|
label: "MiniMax",
|
||||||
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
|
||||||
},
|
},
|
||||||
|
novita: {
|
||||||
|
label: "Novita AI",
|
||||||
|
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
@@ -350,6 +356,12 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"MiniMax-M2.5",
|
"MiniMax-M2.5",
|
||||||
"MiniMax-M2.5-highspeed",
|
"MiniMax-M2.5-highspeed",
|
||||||
],
|
],
|
||||||
|
novita: [
|
||||||
|
// Novita AI models (OpenAI-compatible API)
|
||||||
|
"moonshotai/kimi-k2.5",
|
||||||
|
"zai-org/glm-5",
|
||||||
|
"minimax/minimax-m2.5",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
|
|||||||
3656
package-lock.json
generated
3656
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.13",
|
"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",
|
||||||
|
|||||||
22
packages/mcp-server/package-lock.json
generated
22
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"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.16",
|
"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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -198,9 +221,12 @@ function handleRequest(
|
|||||||
): void {
|
): void {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||||
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
const requestOrigin = req.headers.origin
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204)
|
res.writeHead(204)
|
||||||
@@ -263,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
|
||||||
@@ -344,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) {
|
||||||
@@ -390,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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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
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
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
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
1
public/resnet50.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 39 KiB |
@@ -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 ({
|
||||||
|
|||||||
@@ -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> = {}
|
||||||
|
|||||||
269
tests/unit/template-storage.test.ts
Normal file
269
tests/unit/template-storage.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"packages",
|
"packages",
|
||||||
"electron",
|
"electron",
|
||||||
"electron-standalone",
|
"electron-standalone",
|
||||||
"dist-electron"
|
"dist-electron",
|
||||||
|
"release"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user