mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
1 Commits
fix/mcp-cr
...
fix/mcp-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f09191d2 |
9
.github/CONTRIBUTING.md
vendored
9
.github/CONTRIBUTING.md
vendored
@@ -61,15 +61,6 @@ 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 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 --branch v29.3.5 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 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 --branch v29.3.5 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,4 +75,3 @@ 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](#mcp-server)
|
- [MCP Server (Preview)](#mcp-server-preview)
|
||||||
- [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>RAG Technique Diagram</strong><br />
|
<strong>GCP architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<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>
|
||||||
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Authentication using React and AWS</strong><br />
|
<strong>AWS architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<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>
|
||||||
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Open Innovation</strong><br />
|
<strong>Azure architecture diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<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>
|
||||||
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch</strong><br />
|
<strong>Cat sketch prompt</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,7 +99,9 @@ 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
|
## MCP Server (Preview)
|
||||||
|
|
||||||
|
> **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,106 +175,92 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<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">
|
||||||
ResNet50模型架构动画
|
动画Transformer连接器
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>提示词:</strong> 给我一个带有
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>动画连接器</strong>的Transformer架构图。
|
||||||
of the ResNet50 model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="带动画连接器的Transformer架构"
|
||||||
alt="ResNet50模型架构图"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture 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">
|
||||||
RAG技术图
|
GCP架构图
|
||||||
</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 RAG
|
<strong>提示词:</strong> 使用
|
||||||
architecture diagram for{" "}
|
<strong>GCP图标</strong>
|
||||||
<strong>chat application</strong>. Use
|
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP架构图"
|
||||||
alt="RAG架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
React和AWS认证流程
|
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
|
<strong>提示词:</strong> 使用
|
||||||
authentication process using React with{" "}
|
<strong>AWS图标</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS架构图"
|
||||||
alt="认证架构图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
敏捷Scrum流程
|
Azure架构图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>提示词:</strong> 使用
|
||||||
scrum workflow diagram for software
|
<strong>Azure图标</strong>
|
||||||
development team.
|
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure架构图"
|
||||||
alt="敏捷Scrum流程图"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
开放式创新
|
猫咪素描
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>提示词:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
给我画一只可爱的猫。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫咪绘图"
|
||||||
alt="开放式创新图"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,106 +168,93 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<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">
|
||||||
ResNet50モデルアーキテクチャアニメーション
|
アニメーションTransformerコネクタ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>プロンプト:</strong>{" "}
|
||||||
<strong>animated</strong> architecture diagram
|
<strong>アニメーションコネクタ</strong>
|
||||||
of the ResNet50 model.
|
付きのTransformerアーキテクチャ図を作成してください。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
||||||
alt="ResNet50モデルアーキテクチャ図"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture 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">
|
||||||
RAG技術ダイアグラム
|
GCPアーキテクチャ図
|
||||||
</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 RAG
|
<strong>プロンプト:</strong>{" "}
|
||||||
architecture diagram for{" "}
|
<strong>GCPアイコン</strong>
|
||||||
<strong>chat application</strong>. Use
|
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
connected diagram for data ingestion
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCPアーキテクチャ図"
|
||||||
alt="RAGアーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
ReactとAWSによる認証
|
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
|
<strong>プロンプト:</strong>{" "}
|
||||||
authentication process using React with{" "}
|
<strong>AWSアイコン</strong>
|
||||||
<strong>AWS</strong>. Use Serverless
|
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
architecture.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWSアーキテクチャ図"
|
||||||
alt="認証アーキテクチャ図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
アジャイルスクラムプロセス
|
Azureアーキテクチャ図
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate agile
|
<strong>プロンプト:</strong>{" "}
|
||||||
scrum workflow diagram for software
|
<strong>Azureアイコン</strong>
|
||||||
development team.
|
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azureアーキテクチャ図"
|
||||||
alt="アジャイルスクラム図"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
オープンイノベーション
|
猫のスケッチ
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>プロンプト:</strong>{" "}
|
||||||
visualization of Henry Chesbrough's
|
かわいい猫を描いてください。
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="猫の絵"
|
||||||
alt="オープンイノベーション図"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,106 +182,96 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* ResNet50 Architecture */}
|
{/* Animated Transformer */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated ResNet50 Model Architecture
|
Animated Transformer Connectors
|
||||||
</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</strong> architecture diagram
|
<strong>animated connector</strong> diagram of
|
||||||
of the ResNet50 model.
|
transformer's architecture.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
<Image
|
||||||
<Image
|
src="/animated_connectors.svg"
|
||||||
src="/resnet50.svg"
|
alt="Transformer Architecture with Animated Connectors"
|
||||||
alt="Architecture diagram for ResNet50 model"
|
width={480}
|
||||||
width={480}
|
height={360}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="mx-auto"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diagram Grid */}
|
{/* Cloud Architecture 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">
|
||||||
RAG Technique Diagram
|
GCP Architecture 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 RAG
|
<strong>Prompt:</strong> Generate a GCP
|
||||||
architecture diagram for{" "}
|
architecture diagram with{" "}
|
||||||
<strong>chat application</strong>. Use
|
<strong>GCP icons</strong>. Users connect to
|
||||||
connected diagram for data ingestion
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/gcp_demo.svg"
|
||||||
src="/rag_prod.svg"
|
alt="GCP Architecture Diagram"
|
||||||
alt="RAG Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Authentication using React and AWS
|
AWS Architecture 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
|
<strong>Prompt:</strong> Generate an AWS
|
||||||
authentication process using React with{" "}
|
architecture diagram with{" "}
|
||||||
<strong>AWS</strong>. Use Serverless
|
<strong>AWS icons</strong>. Users connect to
|
||||||
architecture.
|
a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/aws_demo.svg"
|
||||||
src="/auth.svg"
|
alt="AWS Architecture Diagram"
|
||||||
alt="Authentication Architecture Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Agile Scrum Process
|
Azure Architecture 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 agile
|
<strong>Prompt:</strong> Generate an Azure
|
||||||
scrum workflow diagram for software
|
architecture diagram with{" "}
|
||||||
development team.
|
<strong>Azure icons</strong>. Users connect
|
||||||
|
to a frontend hosted on an instance.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/azure_demo.svg"
|
||||||
src="/agile_scrum.svg"
|
alt="Azure Architecture Diagram"
|
||||||
alt="Agile Scrum Diagram"
|
width={400}
|
||||||
width={480}
|
height={300}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Open Innovation
|
Cat Sketch
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Create
|
<strong>Prompt:</strong> Draw a cute cat for
|
||||||
visualization of Henry Chesbrough's
|
me.
|
||||||
Open Innovation model.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
<Image
|
||||||
<Image
|
src="/cat_demo.svg"
|
||||||
src="/inno.svg"
|
alt="Cat Drawing"
|
||||||
alt="Open Innovation Diagram"
|
width={240}
|
||||||
width={480}
|
height={240}
|
||||||
height={360}
|
className="mx-auto"
|
||||||
className="max-w-full max-h-full object-contain"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,16 +11,10 @@ import {
|
|||||||
} from "@/components/ui/resizable"
|
} from "@/components/ui/resizable"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
import { isIndexedDBUsable } from "@/lib/session-storage"
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||||
drawioRef,
|
useDiagram()
|
||||||
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")
|
||||||
@@ -32,8 +26,6 @@ export default function Home() {
|
|||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [isElectron, setIsElectron] = useState(false)
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
const [canPersist, setCanPersist] = useState(false)
|
|
||||||
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
|
||||||
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
||||||
)
|
)
|
||||||
@@ -84,11 +76,6 @@ export default function Home() {
|
|||||||
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const usable = await isIndexedDBUsable()
|
|
||||||
setCanPersist(usable)
|
|
||||||
setCanPersistChecked(true)
|
|
||||||
})()
|
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
@@ -97,13 +84,6 @@ export default function Home() {
|
|||||||
onDrawioLoad()
|
onDrawioLoad()
|
||||||
}, [onDrawioLoad])
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
const handleDrawioAutoSave = useCallback(
|
|
||||||
(data: { xml?: string }) => {
|
|
||||||
handleDiagramAutoSave(data)
|
|
||||||
},
|
|
||||||
[handleDiagramAutoSave],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDarkModeChange = () => {
|
const handleDarkModeChange = () => {
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
@@ -187,32 +167,20 @@ export default function Home() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded && canPersistChecked && (
|
{isLoaded && (
|
||||||
<div
|
<div
|
||||||
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
>
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
autosave
|
|
||||||
onAutoSave={handleDrawioAutoSave}
|
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
configuration={
|
|
||||||
canPersist
|
|
||||||
? { confirmExit: false }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: false,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
// Disable modified tracking only when persistence is available
|
|
||||||
...(canPersist && {
|
|
||||||
modified: false,
|
|
||||||
keepmodified: false,
|
|
||||||
}),
|
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
|
|||||||
@@ -436,18 +436,7 @@ ${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
|
||||||
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
const isSingleSystemProvider = SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider)
|
||||||
// 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
|
||||||
|
|||||||
@@ -345,12 +345,11 @@ export async function POST(req: Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
// GLM, Qwen, Kimi, Qiniu - 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 ||
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ 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,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -20,7 +19,6 @@ 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"
|
||||||
@@ -238,7 +236,6 @@ 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")
|
||||||
@@ -442,7 +439,6 @@ 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
|
||||||
@@ -542,18 +538,6 @@ 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}
|
||||||
@@ -626,12 +610,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TemplateCreateDialog
|
|
||||||
open={showSaveAsTemplate}
|
|
||||||
onOpenChange={setShowSaveAsTemplate}
|
|
||||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
|
||||||
initialPrompt={input.trim()}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BookmarkPlus,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -27,7 +26,6 @@ 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"
|
||||||
@@ -161,10 +159,6 @@ 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({
|
||||||
@@ -184,8 +178,6 @@ 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()
|
||||||
@@ -245,10 +237,6 @@ 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,
|
||||||
@@ -417,7 +405,6 @@ 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) {
|
||||||
@@ -431,17 +418,8 @@ export function ChatMessageDisplay({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttle scroll during streaming to avoid layout thrashing
|
// Single message added - smooth scroll
|
||||||
// Leading + trailing: scroll immediately, then once more after cooldown
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
if (!scrollThrottleRef.current) {
|
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
||||||
scrollThrottleRef.current = setTimeout(() => {
|
|
||||||
scrollThrottleRef.current = null
|
|
||||||
messagesEndRef.current?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
})
|
|
||||||
}, 150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
@@ -672,8 +650,6 @@ 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 : (
|
||||||
@@ -771,42 +747,8 @@ 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" &&
|
||||||
|
|||||||
@@ -138,22 +138,26 @@ 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) => {
|
||||||
resolverRef.current = resolve
|
if (resolverRef && "current" in resolverRef) {
|
||||||
|
resolverRef.current = resolve
|
||||||
|
}
|
||||||
if (saveToHistory) {
|
if (saveToHistory) {
|
||||||
onExport()
|
onExport()
|
||||||
} else {
|
} else {
|
||||||
handleExportWithoutHistory()
|
handleExportWithoutHistory()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) => {
|
new Promise<string>((_, reject) =>
|
||||||
const currentResolver = resolverRef.current
|
setTimeout(
|
||||||
setTimeout(() => {
|
() =>
|
||||||
if (resolverRef.current === currentResolver) {
|
reject(
|
||||||
resolverRef.current = null
|
new Error(
|
||||||
}
|
"Chart export timed out after 10 seconds",
|
||||||
reject(new Error("Chart export timed out after 10 seconds"))
|
),
|
||||||
}, 10000)
|
),
|
||||||
}),
|
10000,
|
||||||
|
),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +605,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSession = sessionManager.currentSession
|
const currentSession = sessionManager.currentSession
|
||||||
if (currentSession) {
|
if (currentSession && currentSession.messages.length > 0) {
|
||||||
// Restore from session manager (IndexedDB)
|
// Restore from session manager (IndexedDB)
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(currentSession)
|
syncUIWithSession(currentSession)
|
||||||
@@ -638,7 +642,7 @@ export default function ChatPanel({
|
|||||||
lastSyncedSessionIdRef.current = newSessionId
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
// Sync UI with new session
|
// Sync UI with new session
|
||||||
if (newSession) {
|
if (newSession && newSession.messages.length > 0) {
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(newSession)
|
syncUIWithSession(newSession)
|
||||||
} else if (!newSession) {
|
} else if (!newSession) {
|
||||||
@@ -693,7 +697,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 || hasDiagramNow) {
|
if (messages.length > 0) {
|
||||||
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,
|
||||||
@@ -715,7 +719,6 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
chartXML,
|
|
||||||
messages,
|
messages,
|
||||||
status,
|
status,
|
||||||
sessionIsAvailable,
|
sessionIsAvailable,
|
||||||
@@ -746,8 +749,7 @@ 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
|
||||||
@@ -973,25 +975,6 @@ 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>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1395,8 +1378,6 @@ export default function ChatPanel({
|
|||||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||||
validationStates={validationStates}
|
validationStates={validationStates}
|
||||||
onImproveWithSuggestions={handleImproveWithSuggestions}
|
onImproveWithSuggestions={handleImproveWithSuggestions}
|
||||||
onSendTemplate={handleSendTemplate}
|
|
||||||
currentInput={input}
|
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { 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 {
|
||||||
@@ -22,8 +21,6 @@ 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
|
||||||
@@ -38,8 +35,6 @@ 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
|
||||||
@@ -49,10 +44,6 @@ interface ChatLobbyProps {
|
|||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
}
|
}
|
||||||
templates?: {
|
|
||||||
title?: string
|
|
||||||
myTemplates?: string
|
|
||||||
}
|
|
||||||
examples?: {
|
examples?: {
|
||||||
quickExamples?: string
|
quickExamples?: string
|
||||||
}
|
}
|
||||||
@@ -84,239 +75,164 @@ 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) {
|
||||||
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
// Track whether examples section is expanded (collapsed by default when there's history)
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||||
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
// Delete confirmation dialog state
|
||||||
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) {
|
||||||
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
// Show full examples when no history
|
||||||
return null
|
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
{panelVisibility.myTemplates && (
|
|
||||||
<TemplatePanel
|
|
||||||
setInput={setInput}
|
|
||||||
onSendTemplate={onSendTemplate}
|
|
||||||
currentInput={currentInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{panelVisibility.quickExamples && (
|
|
||||||
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
// Show history + collapsible examples when there are sessions
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{/* Recent Chats Section */}
|
{/* Recent Chats Section */}
|
||||||
{panelVisibility.recentChats && (
|
<div className="mb-6">
|
||||||
<div className="mb-6">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
</p>
|
||||||
</p>
|
{/* Search Bar */}
|
||||||
{/* Search Bar */}
|
<div className="relative mb-3">
|
||||||
<div className="relative mb-3">
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
placeholder={
|
||||||
placeholder={
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
dict.sessionHistory?.searchPlaceholder ||
|
"Search chats..."
|
||||||
"Search chats..."
|
}
|
||||||
}
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
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"
|
||||||
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 && (
|
||||||
{searchQuery && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setSearchQuery("")}
|
||||||
onClick={() => setSearchQuery("")}
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||||
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" />
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
{sessions
|
||||||
{sessions
|
.filter((session) =>
|
||||||
.filter((session) =>
|
session.title
|
||||||
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 &&
|
)
|
||||||
searchQuery && (
|
.map((session) => (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
{dict.sessionHistory?.noResults ||
|
<div
|
||||||
"No chats found"}
|
key={session.id}
|
||||||
</p>
|
role="button"
|
||||||
)}
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
).length === 0 &&
|
||||||
|
searchQuery && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{dict.sessionHistory?.noResults ||
|
||||||
|
"No chats found"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible 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
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Bookmark, Plus } from "lucide-react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
|
||||||
import {
|
|
||||||
createTemplate,
|
|
||||||
type TemplateCreateInput,
|
|
||||||
} from "@/lib/template-storage"
|
|
||||||
|
|
||||||
interface TemplateCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSuccess: () => void
|
|
||||||
initialPrompt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSuccess,
|
|
||||||
initialPrompt = "",
|
|
||||||
}: TemplateCreateDialogProps) {
|
|
||||||
const dict = useDictionary()
|
|
||||||
const [title, setTitle] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [prompt, setPrompt] = useState("")
|
|
||||||
const [pinned, setPinned] = useState(false)
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Reset form when dialog opens with the latest initialPrompt
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setTitle("")
|
|
||||||
setDescription("")
|
|
||||||
setPrompt(initialPrompt)
|
|
||||||
setPinned(false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
}, [open, initialPrompt])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const trimmedPrompt = prompt.trim()
|
|
||||||
if (!trimmedPrompt) {
|
|
||||||
setError(dict.templates.promptRequired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const input: TemplateCreateInput = {
|
|
||||||
prompt: trimmedPrompt,
|
|
||||||
title: title.trim() || undefined,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
pinned,
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = await createTemplate(input)
|
|
||||||
if (template) {
|
|
||||||
onSuccess()
|
|
||||||
onOpenChange(false)
|
|
||||||
} else {
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create template:", err)
|
|
||||||
setError(dict.templates.createFailed)
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Plus className="w-5 h-5" />
|
|
||||||
{dict.templates.createTitle}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dict.templates.createDescription}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Prompt field - required */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="prompt" className="text-foreground">
|
|
||||||
{dict.templates.promptLabel}
|
|
||||||
<span className="text-destructive ml-1">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="prompt"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
placeholder={dict.templates.promptPlaceholder}
|
|
||||||
className="min-h-[100px] resize-none break-words"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="title" className="text-foreground">
|
|
||||||
{dict.templates.titleLabel}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder={dict.templates.titlePlaceholder}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.titleHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description field - optional */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="description"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
{dict.templates.descriptionLabel}
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
dict.templates.descriptionPlaceholder
|
|
||||||
}
|
|
||||||
className="min-h-[60px] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pinned switch */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="pinned"
|
|
||||||
className="flex items-center gap-2 text-foreground"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-4 h-4" />
|
|
||||||
{dict.templates.pinnedLabel}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{dict.templates.pinnedHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="pinned"
|
|
||||||
checked={pinned}
|
|
||||||
onCheckedChange={setPinned}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{dict.common.cancel}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting
|
|
||||||
? dict.common.loading
|
|
||||||
: dict.templates.createButton}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,622 +0,0 @@
|
|||||||
"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,22 +195,7 @@ export function ToolCallCard({
|
|||||||
{input && isExpanded && (
|
{input && isExpanded && (
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
{typeof input === "object" && input.xml ? (
|
{typeof input === "object" && input.xml ? (
|
||||||
state === "input-streaming" ||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
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) ? (
|
||||||
|
|||||||
@@ -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 scrollbar-thin">
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
||||||
{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="overflow-y-auto scrollbar-thin">
|
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
<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" | "xmlsvg"
|
export type ExportFormat = "drawio" | "png" | "svg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,11 +74,6 @@ 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, useCallback, useEffect, useState } from "react"
|
import { Suspense, 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 {
|
||||||
@@ -114,20 +114,6 @@ 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("")
|
||||||
@@ -178,17 +164,6 @@ 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)
|
||||||
@@ -304,7 +279,7 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
||||||
{/* 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>
|
||||||
@@ -314,7 +289,7 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
<div className="px-6 pb-6">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{/* API Keys & Models */}
|
{/* API Keys & Models */}
|
||||||
{onOpenModelConfig && (
|
{onOpenModelConfig && (
|
||||||
@@ -468,63 +443,6 @@ 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,10 +20,9 @@ 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.MutableRefObject<((value: string) => void) | null>
|
resolverRef: React.Ref<((value: string) => void) | null>
|
||||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
drawioRef: React.Ref<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,
|
||||||
@@ -57,6 +56,8 @@ 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>("")
|
||||||
|
|
||||||
@@ -65,10 +66,6 @@ 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 = () => {
|
||||||
@@ -81,6 +78,24 @@ 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
|
||||||
@@ -219,8 +234,7 @@ 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
|
||||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
if (format === "png" || format === "svg") {
|
||||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,16 +267,6 @@ 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)
|
||||||
@@ -283,8 +287,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat =
|
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
||||||
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
@@ -308,13 +311,8 @@ 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 (view-only)
|
// SVG format
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
@@ -393,7 +391,6 @@ 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>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</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>RAG技术图</strong><br />
|
<strong>GCP架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>React和AWS认证流程</strong><br />
|
<strong>AWS架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>开放式创新</strong><br />
|
<strong>Azure架构图</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
||||||
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -98,7 +98,9 @@ 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>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</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>RAG技術ダイアグラム</strong><br />
|
<strong>GCPアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>ReactとAWSによる認証</strong><br />
|
<strong>AWSアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>オープンイノベーション</strong><br />
|
<strong>Azureアーキテクチャ図</strong><br />
|
||||||
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
||||||
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,7 +96,9 @@ 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を使用できます。
|
||||||
|
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ 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: "127.0.0.1",
|
HOSTNAME: "localhost",
|
||||||
// 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,11 +9,9 @@ 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,
|
||||||
// Legacy production port — tried first to preserve localStorage for existing users
|
// Production mode uses fixed port (61337) to preserve localStorage
|
||||||
legacyProduction: 61337,
|
// Falls back to sequential ports if unavailable
|
||||||
// New production port below the ephemeral range (49152-65535)
|
production: 61337,
|
||||||
// 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,
|
||||||
}
|
}
|
||||||
@@ -29,10 +27,7 @@ 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", (err: NodeJS.ErrnoException) => {
|
server.once("error", () => resolve(false))
|
||||||
console.warn(`Port ${port} unavailable: ${err.code}`)
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
server.once("listening", () => {
|
server.once("listening", () => {
|
||||||
server.close()
|
server.close()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
@@ -44,12 +39,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 (13370) to preserve localStorage
|
* - In production: uses fixed port (61337) 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
|
||||||
@@ -69,16 +64,7 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
allocatedPort = null
|
allocatedPort = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, try legacy port first to preserve existing users' localStorage
|
// Try preferred port first
|
||||||
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
|
||||||
@@ -98,23 +84,9 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: let the OS pick an available port
|
throw new Error(
|
||||||
console.warn(
|
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,5 +113,5 @@ export function getServerUrl(): string {
|
|||||||
"No port allocated yet. Call findAvailablePort() first.",
|
"No port allocated yet. Call findAvailablePort() first.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return `http://127.0.0.1:${allocatedPort}`
|
return `http://localhost:${allocatedPort}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
|||||||
|
|
||||||
// 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 (
|
if (title && !title.includes("localhost")) {
|
||||||
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, novita
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -167,8 +167,3 @@ 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,7 +28,6 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
|||||||
"qwen",
|
"qwen",
|
||||||
"kimi",
|
"kimi",
|
||||||
"qiniu",
|
"qiniu",
|
||||||
"novita",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +98,6 @@ 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
|
||||||
@@ -522,8 +520,7 @@ 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
|
||||||
@@ -558,7 +555,6 @@ 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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1262,8 +1258,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
case "glm":
|
case "glm":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
case "qiniu":
|
case "qiniu":
|
||||||
case "kimi":
|
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(
|
||||||
@@ -1290,7 +1285,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1356,11 +1351,12 @@ 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 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
|
// qwen3.5-plus is a vision model
|
||||||
if (
|
if (
|
||||||
lowerModelId.includes("qwen") &&
|
lowerModelId.includes("qwen") &&
|
||||||
!hasVisionIndicator &&
|
!hasVisionIndicator &&
|
||||||
!lowerModelId.includes("qwen3.5")
|
!lowerModelId.includes("qwen3.5-plus") &&
|
||||||
|
!lowerModelId.includes("qwen3.5-flash")
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
"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",
|
||||||
@@ -128,12 +129,7 @@
|
|||||||
"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",
|
||||||
@@ -144,8 +140,7 @@
|
|||||||
"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!"
|
||||||
},
|
},
|
||||||
@@ -270,52 +265,6 @@
|
|||||||
"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,7 +76,8 @@
|
|||||||
"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": "設定",
|
||||||
@@ -128,12 +129,7 @@
|
|||||||
"disabled": "無効",
|
"disabled": "無効",
|
||||||
"customSystemMessage": "カスタムシステムメッセージ",
|
"customSystemMessage": "カスタムシステムメッセージ",
|
||||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用..."
|
||||||
"panelVisibility": "ロビーパネル",
|
|
||||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
|
||||||
"showRecentChats": "最近のチャット",
|
|
||||||
"showMyTemplates": "マイテンプレート",
|
|
||||||
"showQuickExamples": "クイック例"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -144,8 +140,7 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像",
|
"svg": "SVG 画像"
|
||||||
"xmlsvg": "編集可能 SVG"
|
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存完了!"
|
"savedSuccessfully": "保存完了!"
|
||||||
},
|
},
|
||||||
@@ -355,51 +350,5 @@
|
|||||||
"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,7 +76,8 @@
|
|||||||
"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": "設定",
|
||||||
@@ -128,12 +129,7 @@
|
|||||||
"disabled": "已停用",
|
"disabled": "已停用",
|
||||||
"customSystemMessage": "自訂系統訊息",
|
"customSystemMessage": "自訂系統訊息",
|
||||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案..."
|
||||||
"panelVisibility": "大廳面板",
|
|
||||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
|
||||||
"showRecentChats": "最近聊天",
|
|
||||||
"showMyTemplates": "我的範本",
|
|
||||||
"showQuickExamples": "快速範例"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "儲存圖表",
|
"title": "儲存圖表",
|
||||||
@@ -144,8 +140,7 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 圖片",
|
"png": "PNG 圖片",
|
||||||
"svg": "SVG 圖片",
|
"svg": "SVG 圖片"
|
||||||
"xmlsvg": "可編輯 SVG"
|
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "儲存成功!"
|
"savedSuccessfully": "儲存成功!"
|
||||||
},
|
},
|
||||||
@@ -270,52 +265,6 @@
|
|||||||
"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,7 +76,8 @@
|
|||||||
"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": "设置",
|
||||||
@@ -128,12 +129,7 @@
|
|||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"customSystemMessage": "自定义系统消息",
|
"customSystemMessage": "自定义系统消息",
|
||||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案..."
|
||||||
"panelVisibility": "大厅面板",
|
|
||||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
|
||||||
"showRecentChats": "最近聊天",
|
|
||||||
"showMyTemplates": "我的模板",
|
|
||||||
"showQuickExamples": "快速示例"
|
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -144,8 +140,7 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片",
|
"svg": "SVG 图片"
|
||||||
"xmlsvg": "可编辑 SVG"
|
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存成功!"
|
"savedSuccessfully": "保存成功!"
|
||||||
},
|
},
|
||||||
@@ -270,52 +265,6 @@
|
|||||||
"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,10 +1,9 @@
|
|||||||
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 = 2
|
const DB_VERSION = 1
|
||||||
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
|
||||||
@@ -44,47 +43,10 @@ interface ChatSessionDB extends DBSchema {
|
|||||||
value: ChatSession
|
value: ChatSession
|
||||||
indexes: { "by-updated": number }
|
indexes: { "by-updated": number }
|
||||||
}
|
}
|
||||||
templates: {
|
|
||||||
key: string
|
|
||||||
value: Template
|
|
||||||
indexes: {
|
|
||||||
"by-updated": number
|
|
||||||
"by-pinned": number
|
|
||||||
"by-run-count": number
|
|
||||||
"by-last-used": number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database singleton
|
// Database singleton
|
||||||
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||||
const resetDBPromise = () => {
|
|
||||||
dbPromise = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isClosingError = (error: unknown): boolean => {
|
|
||||||
return (
|
|
||||||
error instanceof DOMException &&
|
|
||||||
error.name === "InvalidStateError" &&
|
|
||||||
/closing/i.test(error.message)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const withDB = async <T>(
|
|
||||||
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
|
|
||||||
): Promise<T> => {
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
return await action(db)
|
|
||||||
} catch (error) {
|
|
||||||
if (isClosingError(error)) {
|
|
||||||
resetDBPromise()
|
|
||||||
const db = await getDB()
|
|
||||||
return await action(db)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||||
if (!dbPromise) {
|
if (!dbPromise) {
|
||||||
@@ -96,42 +58,9 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|||||||
})
|
})
|
||||||
store.createIndex("by-updated", "updatedAt")
|
store.createIndex("by-updated", "updatedAt")
|
||||||
}
|
}
|
||||||
// Version 2: templates store (added by template-storage.ts)
|
// Future migrations: if (oldVersion < 2) { ... }
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
terminated() {
|
|
||||||
resetDBPromise()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
dbPromise
|
|
||||||
.then((db) => {
|
|
||||||
db.onversionchange = () => {
|
|
||||||
db.close()
|
|
||||||
resetDBPromise()
|
|
||||||
}
|
|
||||||
db.onclose = () => {
|
|
||||||
resetDBPromise()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
resetDBPromise()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return dbPromise
|
return dbPromise
|
||||||
}
|
}
|
||||||
@@ -146,46 +75,31 @@ export function isIndexedDBAvailable(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if IndexedDB is actually usable (not just present).
|
|
||||||
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
|
|
||||||
// that other code depends on.
|
|
||||||
export async function isIndexedDBUsable(): Promise<boolean> {
|
|
||||||
if (!isIndexedDBAvailable()) return false
|
|
||||||
try {
|
|
||||||
await getDB()
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRUD Operations
|
// CRUD Operations
|
||||||
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||||
if (!isIndexedDBAvailable()) return []
|
if (!isIndexedDBAvailable()) return []
|
||||||
try {
|
try {
|
||||||
return await withDB(async (db) => {
|
const db = await getDB()
|
||||||
const tx = db.transaction(STORE_NAME, "readonly")
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const metadata: SessionMetadata[] = []
|
const metadata: SessionMetadata[] = []
|
||||||
|
|
||||||
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||||
let cursor = await index.openCursor(null, "prev") // newest first
|
let cursor = await index.openCursor(null, "prev") // newest first
|
||||||
while (cursor) {
|
while (cursor) {
|
||||||
const s = cursor.value
|
const s = cursor.value
|
||||||
metadata.push({
|
metadata.push({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
messageCount: s.messages.length,
|
messageCount: s.messages.length,
|
||||||
hasDiagram:
|
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||||
!!s.diagramXml && s.diagramXml.trim().length > 0,
|
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||||
thumbnailDataUrl: s.thumbnailDataUrl,
|
})
|
||||||
})
|
cursor = await cursor.continue()
|
||||||
cursor = await cursor.continue()
|
}
|
||||||
}
|
return metadata
|
||||||
return metadata
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session metadata:", error)
|
console.error("Failed to get session metadata:", error)
|
||||||
return []
|
return []
|
||||||
@@ -195,9 +109,8 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|||||||
export async function getSession(id: string): Promise<ChatSession | null> {
|
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||||
if (!isIndexedDBAvailable()) return null
|
if (!isIndexedDBAvailable()) return null
|
||||||
try {
|
try {
|
||||||
return await withDB(async (db) => {
|
const db = await getDB()
|
||||||
return (await db.get(STORE_NAME, id)) || null
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session:", error)
|
console.error("Failed to get session:", error)
|
||||||
return null
|
return null
|
||||||
@@ -207,9 +120,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
|
|||||||
export async function saveSession(session: ChatSession): Promise<boolean> {
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||||
if (!isIndexedDBAvailable()) return false
|
if (!isIndexedDBAvailable()) return false
|
||||||
try {
|
try {
|
||||||
await withDB(async (db) => {
|
const db = await getDB()
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle quota exceeded
|
// Handle quota exceeded
|
||||||
@@ -221,9 +133,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
await deleteOldestSession()
|
await deleteOldestSession()
|
||||||
// Retry once
|
// Retry once
|
||||||
try {
|
try {
|
||||||
await withDB(async (db) => {
|
const db = await getDB()
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -242,9 +153,8 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
export async function deleteSession(id: string): Promise<void> {
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
await withDB(async (db) => {
|
const db = await getDB()
|
||||||
await db.delete(STORE_NAME, id)
|
await db.delete(STORE_NAME, id)
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete session:", error)
|
console.error("Failed to delete session:", error)
|
||||||
}
|
}
|
||||||
@@ -253,9 +163,8 @@ export async function deleteSession(id: string): Promise<void> {
|
|||||||
export async function getSessionCount(): Promise<number> {
|
export async function getSessionCount(): Promise<number> {
|
||||||
if (!isIndexedDBAvailable()) return 0
|
if (!isIndexedDBAvailable()) return 0
|
||||||
try {
|
try {
|
||||||
return await withDB(async (db) => {
|
const db = await getDB()
|
||||||
return await db.count(STORE_NAME)
|
return await db.count(STORE_NAME)
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get session count:", error)
|
console.error("Failed to get session count:", error)
|
||||||
return 0
|
return 0
|
||||||
@@ -265,15 +174,14 @@ export async function getSessionCount(): Promise<number> {
|
|||||||
export async function deleteOldestSession(): Promise<void> {
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
await withDB(async (db) => {
|
const db = await getDB()
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite")
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
const index = tx.store.index("by-updated")
|
const index = tx.store.index("by-updated")
|
||||||
const cursor = await index.openCursor()
|
const cursor = await index.openCursor()
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
await cursor.delete()
|
await cursor.delete()
|
||||||
}
|
}
|
||||||
await tx.done
|
await tx.done
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete oldest session:", error)
|
console.error("Failed to delete oldest session:", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,4 @@ 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
|
||||||
|
|||||||
@@ -1,437 +0,0 @@
|
|||||||
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
|
|
||||||
const resetDBPromise = () => {
|
|
||||||
dbPromise = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isClosingError = (error: unknown): boolean => {
|
|
||||||
return (
|
|
||||||
error instanceof DOMException &&
|
|
||||||
error.name === "InvalidStateError" &&
|
|
||||||
/closing/i.test(error.message)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const withDB = async <T>(
|
|
||||||
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
|
|
||||||
): Promise<T> => {
|
|
||||||
try {
|
|
||||||
const db = await getDB()
|
|
||||||
return await action(db)
|
|
||||||
} catch (error) {
|
|
||||||
if (isClosingError(error)) {
|
|
||||||
resetDBPromise()
|
|
||||||
const db = await getDB()
|
|
||||||
return await action(db)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDB(): Promise<IDBPDatabase<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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
terminated() {
|
|
||||||
resetDBPromise()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
dbPromise
|
|
||||||
.then((db) => {
|
|
||||||
db.onversionchange = () => {
|
|
||||||
db.close()
|
|
||||||
resetDBPromise()
|
|
||||||
}
|
|
||||||
db.onclose = () => {
|
|
||||||
resetDBPromise()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
resetDBPromise()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
return await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
return await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
return await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
return await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
await withDB(async (db) => {
|
|
||||||
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 {
|
|
||||||
await withDB(async (db) => {
|
|
||||||
await db.put(STORE_NAME, newTemplate)
|
|
||||||
})
|
|
||||||
existingKeys.add(key)
|
|
||||||
imported++
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to import template:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { imported, skipped }
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ export type ProviderName =
|
|||||||
| "qiniu"
|
| "qiniu"
|
||||||
| "kimi"
|
| "kimi"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
| "novita"
|
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -102,7 +101,6 @@ 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
|
||||||
@@ -181,10 +179,6 @@ 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
|
||||||
@@ -356,12 +350,6 @@ 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
|
||||||
|
|||||||
2888
package-lock.json
generated
2888
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.14",
|
"version": "0.4.13",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@opennextjs/cloudflare": "^1.17.1",
|
"@opennextjs/cloudflare": "^1.17.1",
|
||||||
"@openrouter/ai-sdk-provider": "^1.5.4",
|
"@openrouter/ai-sdk-provider": "^1.5.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.212.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,7 +72,7 @@
|
|||||||
"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.577.0",
|
"lucide-react": "^0.575.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",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "2.4.10",
|
"@biomejs/biome": "2.4.4",
|
||||||
"@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",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"esbuild": "^0.27.2",
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.3",
|
||||||
"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",
|
||||||
|
|||||||
16
packages/mcp-server/package-lock.json
generated
16
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.2.0",
|
"version": "0.1.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.2.0",
|
"version": "0.1.17",
|
||||||
"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.29.0",
|
"version": "1.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||||
"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.12.0",
|
"version": "24.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.2.0",
|
"version": "0.1.17",
|
||||||
"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,29 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
@@ -178,7 +155,7 @@ export function startHttpServer(port = 6002): Promise<number> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.listen(port, "127.0.0.1", () => {
|
server.listen(port, () => {
|
||||||
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)
|
||||||
@@ -289,7 +266,11 @@ function handleStateApi(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
readBody(req, res, (body) => {
|
let 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
|
||||||
@@ -366,7 +347,11 @@ function handleRestoreApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readBody(req, res, (body) => {
|
let 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) {
|
||||||
@@ -408,7 +393,11 @@ function handleHistorySvgApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readBody(req, res, (body) => {
|
let 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,22 +155,19 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"create_new_diagram",
|
"create_new_diagram",
|
||||||
{
|
{
|
||||||
description: `Create a NEW diagram from mxGraphModel XML. ONLY use this when creating a diagram from scratch.
|
description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely.
|
||||||
|
|
||||||
⚠️ 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 (no existing diagram)
|
- Creating a new diagram from scratch
|
||||||
- The user explicitly asks to "start over" or "create a new diagram"
|
- Replacing the current diagram with a completely different one
|
||||||
|
- Major structural changes that require regenerating the diagram
|
||||||
|
|
||||||
When to use edit_diagram instead (ALWAYS prefer edit_diagram if a diagram already exists):
|
When to use edit_diagram instead:
|
||||||
- ANY modifications to an existing diagram
|
- Small modifications to existing diagram
|
||||||
- Adding/removing/moving elements
|
- Adding/removing individual elements
|
||||||
- Changing labels, colors, styles, or positions
|
- Changing labels, colors, 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,8 +12,7 @@
|
|||||||
"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"]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 30 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 42 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 27 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 35 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 39 KiB |
@@ -200,8 +200,6 @@ 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)
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
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,7 +35,6 @@
|
|||||||
"packages",
|
"packages",
|
||||||
"electron",
|
"electron",
|
||||||
"electron-standalone",
|
"electron-standalone",
|
||||||
"dist-electron",
|
"dist-electron"
|
||||||
"release"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user