mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Compare commits
52 Commits
fix/electr
...
fix/mcp-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea48a8426 | ||
|
|
0cd1260172 | ||
|
|
49bfb51b10 | ||
|
|
ccd9c1f48e | ||
|
|
622aa8683d | ||
|
|
43ddb7a999 | ||
|
|
b9fdf9538c | ||
|
|
1f31692701 | ||
|
|
31819f413c | ||
|
|
41c410c2ba | ||
|
|
f593901fee | ||
|
|
6c6cf98019 | ||
|
|
f5ea5a0edd | ||
|
|
cb8127920c | ||
|
|
29538782fc | ||
|
|
8d6d33bfc9 | ||
|
|
0690f05399 | ||
|
|
f9c30d95b2 | ||
|
|
95e3e97b8b | ||
|
|
16801f69b4 | ||
|
|
4f41cd1f01 | ||
|
|
3ca46f44c1 | ||
|
|
a5871ded9b | ||
|
|
c3ff41ce72 | ||
|
|
9efec68cbc | ||
|
|
529a3fe2e5 | ||
|
|
571167f9dd | ||
|
|
67b0d77fa1 | ||
|
|
524b77a948 | ||
|
|
9bf0c7f23f | ||
|
|
9b9a291e2b | ||
|
|
ada2c840b9 | ||
|
|
43cc4cb657 | ||
|
|
fd84aa70db | ||
|
|
8b0beb68c0 | ||
|
|
7a6a933cf6 | ||
|
|
05bda50b2a | ||
|
|
e7453e86a6 | ||
|
|
be4bc916fd | ||
|
|
c4ba5d4ea8 | ||
|
|
ff07975574 | ||
|
|
ffac74b0ff | ||
|
|
69bd13bc93 | ||
|
|
dd9d79d2d6 | ||
|
|
f3c3614b53 | ||
|
|
6586c53093 | ||
|
|
f19dc919e1 | ||
|
|
8d92474f73 | ||
|
|
cc6f09615d | ||
|
|
be20c09c89 | ||
|
|
76487fb7ea | ||
|
|
c5de1a16ad |
19
.github/CONTRIBUTING.md
vendored
19
.github/CONTRIBUTING.md
vendored
@@ -47,6 +47,10 @@ To run tests with UI mode:
|
|||||||
npx playwright test --ui
|
npx playwright test --ui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Before You Start
|
||||||
|
|
||||||
|
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
@@ -57,6 +61,21 @@ npx playwright test --ui
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
|
||||||
|
- **Valid suggestions**: Please address them in your code.
|
||||||
|
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
Include steps to reproduce, expected vs actual behavior, and AI provider used.
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -40,5 +40,3 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Security audit
|
|
||||||
run: npm audit --audit-level=high --omit=dev
|
|
||||||
|
|||||||
4
.github/workflows/electron-release.yml
vendored
4
.github/workflows/electron-release.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Download draw.io static files for offline use
|
- name: Download draw.io static files for offline use
|
||||||
run: |
|
run: |
|
||||||
rm -rf public/drawio
|
rm -rf public/drawio
|
||||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
mkdir -p public/drawio
|
mkdir -p public/drawio
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
rm -rf public/drawio/WEB-INF
|
rm -rf public/drawio/WEB-INF
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
rm -rf public/drawio
|
rm -rf public/drawio
|
||||||
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
|
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
|
||||||
mkdir -p public/drawio
|
mkdir -p public/drawio
|
||||||
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
|
||||||
rm -rf public/drawio/WEB-INF
|
rm -rf public/drawio/WEB-INF
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,3 +72,7 @@ CLAUDE.md
|
|||||||
.edgeone
|
.edgeone
|
||||||
opencode.json
|
opencode.json
|
||||||
ai-models.json
|
ai-models.json
|
||||||
|
|
||||||
|
# local backups
|
||||||
|
*.bak
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Stage 2: Build application
|
# Stage 2: Build application
|
||||||
@@ -34,6 +35,11 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
|||||||
ARG NEXT_PUBLIC_BASE_PATH=""
|
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||||
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||||
|
|
||||||
|
# Control sponsorship and self-hosting messaging in quota notifications.
|
||||||
|
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
|
||||||
|
ARG NEXT_PUBLIC_SELFHOSTED=""
|
||||||
|
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
|
||||||
|
|
||||||
# Build Next.js application (standalone mode)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -19,7 +19,7 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
|
|||||||
|
|
||||||
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
|
||||||
|
|
||||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
|
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
||||||
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||||
@@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Table of Contents](#table-of-contents)
|
- [Table of Contents](#table-of-contents)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [MCP Server (Preview)](#mcp-server-preview)
|
- [MCP Server](#mcp-server)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Try it Online](#try-it-online)
|
- [Try it Online](#try-it-online)
|
||||||
@@ -63,24 +63,24 @@ Here are some example prompts and their generated diagrams:
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCP architecture diagram</strong><br />
|
<strong>RAG Technique Diagram</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
|
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS architecture diagram</strong><br />
|
<strong>Authentication using React and AWS</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
|
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure architecture diagram</strong><br />
|
<strong>Open Innovation</strong><br />
|
||||||
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
|
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Cat sketch prompt</strong><br />
|
<strong>Cat sketch</strong><br />
|
||||||
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
|
||||||
</td>
|
</td>
|
||||||
@@ -99,9 +99,7 @@ Here are some example prompts and their generated diagrams:
|
|||||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||||
|
|
||||||
## MCP Server (Preview)
|
## MCP Server
|
||||||
|
|
||||||
> **Preview Feature**: This feature is experimental and may not be stable.
|
|
||||||
|
|
||||||
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
|
||||||
|
|
||||||
@@ -202,7 +200,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
|
|
||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
||||||
- AWS Bedrock (default)
|
- AWS Bedrock (default)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -244,7 +242,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
|
|
||||||
## Support & Contact
|
## Support & Contact
|
||||||
|
|
||||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||||
|
|
||||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function AboutCN() {
|
|||||||
<p>
|
<p>
|
||||||
好消息!感谢{" "}
|
好消息!感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -87,7 +87,7 @@ export default function AboutCN() {
|
|||||||
</a>
|
</a>
|
||||||
的慷慨赞助,演示站点现已接入强大的{" "}
|
的慷慨赞助,演示站点现已接入强大的{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
模型,图表生成效果更佳!点击链接注册即可领取{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -97,6 +97,23 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Poster */}
|
||||||
|
<div className="text-center mb-5">
|
||||||
|
<a
|
||||||
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/volcengine-invite.png"
|
||||||
|
alt="火山引擎方舟 Coding Plan"
|
||||||
|
width={300}
|
||||||
|
height={400}
|
||||||
|
className="mx-auto rounded-lg"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bring Your Own Key */}
|
{/* Bring Your Own Key */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h4 className="text-base font-bold text-gray-900 mb-2">
|
<h4 className="text-base font-bold text-gray-900 mb-2">
|
||||||
@@ -158,95 +175,109 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
动画Transformer连接器
|
ResNet50模型架构动画
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>提示词:</strong> 给我一个带有
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>动画连接器</strong>的Transformer架构图。
|
<strong>animated</strong> architecture diagram
|
||||||
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/animated_connectors.svg"
|
src="/resnet50.svg"
|
||||||
alt="带动画连接器的Transformer架构"
|
alt="ResNet50模型架构图"
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCP架构图
|
RAG技术图
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCP图标</strong>
|
architecture diagram for{" "}
|
||||||
生成一个GCP架构图。用户连接到托管在实例上的前端。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/gcp_demo.svg"
|
src="/rag_prod.svg"
|
||||||
alt="GCP架构图"
|
alt="RAG架构图"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWS架构图
|
React和AWS认证流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWS图标</strong>
|
authentication process using React with{" "}
|
||||||
生成一个AWS架构图。用户连接到托管在实例上的前端。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/aws_demo.svg"
|
src="/auth.svg"
|
||||||
alt="AWS架构图"
|
alt="认证架构图"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azure架构图
|
敏捷Scrum流程
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong> 使用
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azure图标</strong>
|
scrum workflow diagram for software
|
||||||
生成一个Azure架构图。用户连接到托管在实例上的前端。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/azure_demo.svg"
|
src="/agile_scrum.svg"
|
||||||
alt="Azure架构图"
|
alt="敏捷Scrum流程图"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
猫咪素描
|
开放式创新
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>提示词:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
给我画一只可爱的猫。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/cat_demo.svg"
|
src="/inno.svg"
|
||||||
alt="猫咪绘图"
|
alt="开放式创新图"
|
||||||
width={240}
|
width={480}
|
||||||
height={240}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
@@ -277,7 +308,7 @@ export default function AboutCN() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -312,7 +343,7 @@ export default function AboutCN() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
特别感谢{" "}
|
特别感谢{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function AboutJA() {
|
|||||||
<p>
|
<p>
|
||||||
朗報です!
|
朗報です!
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -95,7 +95,7 @@ export default function AboutJA() {
|
|||||||
</a>
|
</a>
|
||||||
様のご支援により、デモサイトでは強力な{" "}
|
様のご支援により、デモサイトでは強力な{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
@@ -168,96 +168,109 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
アニメーションTransformerコネクタ
|
ResNet50モデルアーキテクチャアニメーション
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>アニメーションコネクタ</strong>
|
<strong>animated</strong> architecture diagram
|
||||||
付きのTransformerアーキテクチャ図を作成してください。
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/animated_connectors.svg"
|
src="/resnet50.svg"
|
||||||
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
|
alt="ResNet50モデルアーキテクチャ図"
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCPアーキテクチャ図
|
RAG技術ダイアグラム
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
<strong>GCPアイコン</strong>
|
architecture diagram for{" "}
|
||||||
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>chat application</strong>. Use
|
||||||
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/gcp_demo.svg"
|
src="/rag_prod.svg"
|
||||||
alt="GCPアーキテクチャ図"
|
alt="RAGアーキテクチャ図"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWSアーキテクチャ図
|
ReactとAWSによる認証
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate
|
||||||
<strong>AWSアイコン</strong>
|
authentication process using React with{" "}
|
||||||
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
<strong>AWS</strong>. Use Serverless
|
||||||
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/aws_demo.svg"
|
src="/auth.svg"
|
||||||
alt="AWSアーキテクチャ図"
|
alt="認証アーキテクチャ図"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azureアーキテクチャ図
|
アジャイルスクラムプロセス
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Generate agile
|
||||||
<strong>Azureアイコン</strong>
|
scrum workflow diagram for software
|
||||||
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
|
development team.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/azure_demo.svg"
|
src="/agile_scrum.svg"
|
||||||
alt="Azureアーキテクチャ図"
|
alt="アジャイルスクラム図"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
猫のスケッチ
|
オープンイノベーション
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>プロンプト:</strong>{" "}
|
<strong>Prompt:</strong> Create
|
||||||
かわいい猫を描いてください。
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/cat_demo.svg"
|
src="/inno.svg"
|
||||||
alt="猫の絵"
|
alt="オープンイノベーション図"
|
||||||
width={240}
|
width={480}
|
||||||
height={240}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
@@ -292,7 +305,7 @@ export default function AboutJA() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -327,7 +340,7 @@ export default function AboutJA() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
デモサイトのAPIトークン使用を支援してくださった{" "}
|
デモサイトのAPIトークン使用を支援してくださった{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function About() {
|
|||||||
Great news! Thanks to the generous
|
Great news! Thanks to the generous
|
||||||
sponsorship from{" "}
|
sponsorship from{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-semibold text-blue-600 hover:underline"
|
className="font-semibold text-blue-600 hover:underline"
|
||||||
@@ -96,7 +96,7 @@ export default function About() {
|
|||||||
</a>
|
</a>
|
||||||
, the demo site now uses the powerful{" "}
|
, the demo site now uses the powerful{" "}
|
||||||
<span className="font-semibold text-amber-700">
|
<span className="font-semibold text-amber-700">
|
||||||
K2-thinking
|
glm-4.7
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
model for better diagram generation! Sign up
|
model for better diagram generation! Sign up
|
||||||
via the link to get{" "}
|
via the link to get{" "}
|
||||||
@@ -182,99 +182,109 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Animated Transformer */}
|
{/* ResNet50 Architecture */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Animated Transformer Connectors
|
Animated ResNet50 Model Architecture
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
<strong>Prompt:</strong> Give me an{" "}
|
<strong>Prompt:</strong> Give me an{" "}
|
||||||
<strong>animated connector</strong> diagram of
|
<strong>animated</strong> architecture diagram
|
||||||
transformer's architecture.
|
of the ResNet50 model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/animated_connectors.svg"
|
src="/resnet50.svg"
|
||||||
alt="Transformer Architecture with Animated Connectors"
|
alt="Architecture diagram for ResNet50 model"
|
||||||
width={480}
|
width={480}
|
||||||
height={360}
|
height={360}
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cloud Architecture Grid */}
|
{/* Diagram Grid */}
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
GCP Architecture Diagram
|
RAG Technique Diagram
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate a GCP
|
<strong>Prompt:</strong> Generate a RAG
|
||||||
architecture diagram with{" "}
|
architecture diagram for{" "}
|
||||||
<strong>GCP icons</strong>. Users connect to
|
<strong>chat application</strong>. Use
|
||||||
a frontend hosted on an instance.
|
connected diagram for data ingestion
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/gcp_demo.svg"
|
src="/rag_prod.svg"
|
||||||
alt="GCP Architecture Diagram"
|
alt="RAG Architecture Diagram"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
AWS Architecture Diagram
|
Authentication using React and AWS
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an AWS
|
<strong>Prompt:</strong> Generate
|
||||||
architecture diagram with{" "}
|
authentication process using React with{" "}
|
||||||
<strong>AWS icons</strong>. Users connect to
|
<strong>AWS</strong>. Use Serverless
|
||||||
a frontend hosted on an instance.
|
architecture.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/aws_demo.svg"
|
src="/auth.svg"
|
||||||
alt="AWS Architecture Diagram"
|
alt="Authentication Architecture Diagram"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Azure Architecture Diagram
|
Agile Scrum Process
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Generate an Azure
|
<strong>Prompt:</strong> Generate agile
|
||||||
architecture diagram with{" "}
|
scrum workflow diagram for software
|
||||||
<strong>Azure icons</strong>. Users connect
|
development team.
|
||||||
to a frontend hosted on an instance.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/azure_demo.svg"
|
src="/agile_scrum.svg"
|
||||||
alt="Azure Architecture Diagram"
|
alt="Agile Scrum Diagram"
|
||||||
width={400}
|
width={480}
|
||||||
height={300}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Cat Sketch
|
Open Innovation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
<strong>Prompt:</strong> Draw a cute cat for
|
<strong>Prompt:</strong> Create
|
||||||
me.
|
visualization of Henry Chesbrough's
|
||||||
|
Open Innovation model.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
|
||||||
<Image
|
<Image
|
||||||
src="/cat_demo.svg"
|
src="/inno.svg"
|
||||||
alt="Cat Drawing"
|
alt="Open Innovation Diagram"
|
||||||
width={240}
|
width={480}
|
||||||
height={240}
|
height={360}
|
||||||
className="mx-auto"
|
className="max-w-full max-h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
|
||||||
@@ -311,7 +321,7 @@ export default function About() {
|
|||||||
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="list-disc pl-6 text-gray-700 space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
@@ -348,7 +358,7 @@ export default function About() {
|
|||||||
<p className="text-gray-700 mb-4 font-semibold">
|
<p className="text-gray-700 mb-4 font-semibold">
|
||||||
Special thanks to{" "}
|
Special thanks to{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
|
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ 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 { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
@@ -26,6 +32,8 @@ 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",
|
||||||
)
|
)
|
||||||
@@ -76,6 +84,11 @@ 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])
|
||||||
|
|
||||||
@@ -84,6 +97,13 @@ 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)
|
||||||
@@ -167,20 +187,32 @@ 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 && (
|
{isLoaded && canPersistChecked && (
|
||||||
<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,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import path from "path"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import {
|
import {
|
||||||
getAIModel,
|
getAIModel,
|
||||||
|
SINGLE_SYSTEM_PROVIDERS,
|
||||||
supportsImageInput,
|
supportsImageInput,
|
||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
@@ -89,7 +90,12 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, xml, previousXml, sessionId } = await req.json()
|
const body = await req.json()
|
||||||
|
const { messages, xml, previousXml, sessionId } = body
|
||||||
|
const customSystemMessage =
|
||||||
|
typeof body.customSystemMessage === "string"
|
||||||
|
? body.customSystemMessage.slice(0, 5000)
|
||||||
|
: ""
|
||||||
|
|
||||||
// Get user ID for Langfuse tracking and quota
|
// Get user ID for Langfuse tracking and quota
|
||||||
const userId = getUserIdFromRequest(req)
|
const userId = getUserIdFromRequest(req)
|
||||||
@@ -235,8 +241,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get AI model with optional client overrides
|
// Get AI model with optional client overrides
|
||||||
const { model, providerOptions, headers, modelId } =
|
const {
|
||||||
getAIModel(clientOverrides)
|
model,
|
||||||
|
providerOptions,
|
||||||
|
headers,
|
||||||
|
modelId,
|
||||||
|
provider: resolvedProvider,
|
||||||
|
} = getAIModel(clientOverrides)
|
||||||
|
|
||||||
// Check if model supports prompt caching
|
// Check if model supports prompt caching
|
||||||
const shouldCache = supportsPromptCaching(modelId)
|
const shouldCache = supportsPromptCaching(modelId)
|
||||||
@@ -246,6 +257,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
|||||||
|
|
||||||
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
|
||||||
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
const systemMessage = getSystemPrompt(modelId, minimalStyle)
|
||||||
|
const finalSystemMessage = customSystemMessage
|
||||||
|
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
|
||||||
|
: systemMessage
|
||||||
|
|
||||||
// Extract file parts (images) from the last user message
|
// Extract file parts (images) from the last user message
|
||||||
const fileParts =
|
const fileParts =
|
||||||
@@ -418,15 +432,51 @@ ${userInputText}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// System messages with multiple cache breakpoints for optimal caching:
|
// System messages with multiple cache breakpoints for optimal caching:
|
||||||
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
|
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
|
||||||
// - 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
|
||||||
// This allows: if only user message changes, both system caches are reused
|
// Some providers (e.g. MiniMax) don't support multiple system messages
|
||||||
// if XML changes, instruction cache is still reused
|
// Merge them into a single system message for compatibility
|
||||||
const systemMessages = [
|
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
|
||||||
// Cache breakpoint 1: Instructions (rarely change)
|
// 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 = `${
|
||||||
|
previousXml
|
||||||
|
? `Previous diagram XML (before user's last message):
|
||||||
|
"""xml
|
||||||
|
${previousXml}
|
||||||
|
"""
|
||||||
|
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}Current diagram XML (AUTHORITATIVE - the source of truth):
|
||||||
|
"""xml
|
||||||
|
${xml || ""}
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
|
||||||
|
|
||||||
|
const systemMessages = isSingleSystemProvider
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
role: "system" as const,
|
role: "system" as const,
|
||||||
content: systemMessage,
|
content: `${finalSystemMessage}\n\n${xmlContext}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Cache breakpoint 1: Instructions (+ optional custom instructions)
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: finalSystemMessage,
|
||||||
...(shouldCache && {
|
...(shouldCache && {
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
@@ -436,7 +486,7 @@ ${userInputText}
|
|||||||
// Cache breakpoint 2: Previous and Current diagram XML context
|
// Cache breakpoint 2: Previous and Current diagram XML context
|
||||||
{
|
{
|
||||||
role: "system" as const,
|
role: "system" as const,
|
||||||
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`,
|
content: xmlContext,
|
||||||
...(shouldCache && {
|
...(shouldCache && {
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
bedrock: { cachePoint: { type: "default" } },
|
bedrock: { cachePoint: { type: "default" } },
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
|||||||
import { generateText } from "ai"
|
import { generateText } from "ai"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { createOllama } from "ollama-ai-provider-v2"
|
import { createOllama } from "ollama-ai-provider-v2"
|
||||||
|
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
|
||||||
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
|
||||||
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
@@ -319,6 +321,59 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "minimax": {
|
||||||
|
const rawUrl =
|
||||||
|
baseUrl ||
|
||||||
|
PROVIDER_INFO.minimax?.defaultBaseUrl ||
|
||||||
|
"https://api.minimaxi.com/anthropic"
|
||||||
|
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
|
||||||
|
normalizeMiniMaxBaseURL(rawUrl)
|
||||||
|
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
const minimax = createAnthropic({
|
||||||
|
apiKey,
|
||||||
|
baseURL: minimaxBaseUrl,
|
||||||
|
})
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
} else {
|
||||||
|
const minimax = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: minimaxBaseUrl,
|
||||||
|
})
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "kimi":
|
||||||
|
case "qiniu":
|
||||||
|
case "novita": {
|
||||||
|
const baseURL =
|
||||||
|
baseUrl ||
|
||||||
|
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||||
|
""
|
||||||
|
|
||||||
|
if (!baseURL) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
valid: false,
|
||||||
|
error: `No base URL configured for provider: ${provider}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = openai.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -141,9 +141,6 @@ export default function ExamplePanel({
|
|||||||
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
||||||
{dict.examples.mcpServer}
|
{dict.examples.mcpServer}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
|
|
||||||
{dict.examples.preview}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{dict.examples.mcpDescription}
|
{dict.examples.mcpDescription}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Link,
|
Link,
|
||||||
Loader2,
|
|
||||||
Send,
|
Send,
|
||||||
Square,
|
Square,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "react"
|
} from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ErrorToast } from "@/components/error-toast"
|
import { ErrorToast } from "@/components/error-toast"
|
||||||
import { HistoryDialog } from "@/components/history-dialog"
|
import { HistoryDialog } from "@/components/history-dialog"
|
||||||
import { ModelSelector } from "@/components/model-selector"
|
import { ModelSelector } from "@/components/model-selector"
|
||||||
@@ -237,6 +238,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
|
||||||
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
|
||||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||||
@@ -440,6 +442,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
id="chat-form"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className={`w-full transition-all duration-200 ${
|
className={`w-full transition-all duration-200 ${
|
||||||
isDragging
|
isDragging
|
||||||
@@ -539,6 +542,18 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSaveAsTemplate(true)}
|
||||||
|
disabled={isDisabled || !input.trim()}
|
||||||
|
tooltipContent={dict.templates.saveAsTemplate}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -611,6 +626,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|||||||
isExtracting={isExtractingUrl}
|
isExtracting={isExtractingUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={showSaveAsTemplate}
|
||||||
|
onOpenChange={setShowSaveAsTemplate}
|
||||||
|
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||||
|
initialPrompt={input.trim()}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { UIMessage } from "ai"
|
import type { UIMessage } from "ai"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookmarkPlus,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
import { ChatLobby } from "@/components/chat/ChatLobby"
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
|
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
|
||||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import type { ValidationState } from "@/components/chat/ValidationCard"
|
import type { ValidationState } from "@/components/chat/ValidationCard"
|
||||||
@@ -159,6 +161,10 @@ interface ChatMessageDisplayProps {
|
|||||||
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||||
validationStates?: Record<string, ValidationState>
|
validationStates?: Record<string, ValidationState>
|
||||||
onImproveWithSuggestions?: (feedback: string) => void
|
onImproveWithSuggestions?: (feedback: string) => void
|
||||||
|
onSendTemplate?: (
|
||||||
|
template: import("@/lib/template-storage").Template,
|
||||||
|
) => void
|
||||||
|
currentInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -178,6 +184,8 @@ export function ChatMessageDisplay({
|
|||||||
loadedMessageIdsRef,
|
loadedMessageIdsRef,
|
||||||
validationStates = {},
|
validationStates = {},
|
||||||
onImproveWithSuggestions,
|
onImproveWithSuggestions,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
@@ -237,6 +245,10 @@ export function ChatMessageDisplay({
|
|||||||
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
const [expandedPdfSections, setExpandedPdfSections] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({})
|
>({})
|
||||||
|
// Track "Save as Template" dialog
|
||||||
|
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
const setCopyState = (
|
const setCopyState = (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
@@ -405,6 +417,7 @@ export function ChatMessageDisplay({
|
|||||||
|
|
||||||
// Track previous message count to detect bulk loads vs streaming
|
// Track previous message count to detect bulk loads vs streaming
|
||||||
const prevMessageCountRef = useRef(0)
|
const prevMessageCountRef = useRef(0)
|
||||||
|
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && messages.length > 0) {
|
if (messagesEndRef.current && messages.length > 0) {
|
||||||
@@ -418,8 +431,17 @@ export function ChatMessageDisplay({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single message added - smooth scroll
|
// Throttle scroll during streaming to avoid layout thrashing
|
||||||
|
// Leading + trailing: scroll immediately, then once more after cooldown
|
||||||
|
if (!scrollThrottleRef.current) {
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
|
scrollThrottleRef.current = setTimeout(() => {
|
||||||
|
scrollThrottleRef.current = null
|
||||||
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
@@ -650,6 +672,8 @@ export function ChatMessageDisplay({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
dict={dict}
|
dict={dict}
|
||||||
/>
|
/>
|
||||||
) : messages.length === 0 ? null : (
|
) : messages.length === 0 ? null : (
|
||||||
@@ -747,8 +771,42 @@ export function ChatMessageDisplay({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Save as Template button - only for user messages */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSaveAsTemplateMessageId(
|
||||||
|
message.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
title={
|
||||||
|
dict.templates
|
||||||
|
?.saveAsTemplate ||
|
||||||
|
"Save as Template"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Save as Template Dialog */}
|
||||||
|
{saveAsTemplateMessageId === message.id && (
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open)
|
||||||
|
setSaveAsTemplateMessageId(null)
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setSaveAsTemplateMessageId(null)
|
||||||
|
}}
|
||||||
|
initialPrompt={getUserOriginalText(
|
||||||
|
message,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%] min-w-0">
|
||||||
{/* Reasoning blocks - displayed first for assistant messages */}
|
{/* Reasoning blocks - displayed first for assistant messages */}
|
||||||
{message.role === "assistant" &&
|
{message.role === "assistant" &&
|
||||||
|
|||||||
@@ -138,26 +138,22 @@ export default function ChatPanel({
|
|||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
if (resolverRef && "current" in resolverRef) {
|
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve
|
||||||
}
|
|
||||||
if (saveToHistory) {
|
if (saveToHistory) {
|
||||||
onExport()
|
onExport()
|
||||||
} else {
|
} else {
|
||||||
handleExportWithoutHistory()
|
handleExportWithoutHistory()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) => {
|
||||||
setTimeout(
|
const currentResolver = resolverRef.current
|
||||||
() =>
|
setTimeout(() => {
|
||||||
reject(
|
if (resolverRef.current === currentResolver) {
|
||||||
new Error(
|
resolverRef.current = null
|
||||||
"Chart export timed out after 10 seconds",
|
}
|
||||||
),
|
reject(new Error("Chart export timed out after 10 seconds"))
|
||||||
),
|
}, 10000)
|
||||||
10000,
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +176,7 @@ export default function ChatPanel({
|
|||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
const [tpmLimit, setTpmLimit] = useState(0)
|
||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||||
|
const [customSystemMessage, setCustomSystemMessage] = useState("")
|
||||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||||
|
|
||||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||||
@@ -198,6 +195,14 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load custom system message from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)
|
||||||
|
if (stored !== null) {
|
||||||
|
setCustomSystemMessage(stored)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Check config on mount
|
// Check config on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(getApiEndpoint("/api/config"))
|
fetch(getApiEndpoint("/api/config"))
|
||||||
@@ -308,6 +313,12 @@ export default function ChatPanel({
|
|||||||
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
|
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Handler for custom system message change
|
||||||
|
const handleCustomSystemMessageChange = useCallback((value: string) => {
|
||||||
|
setCustomSystemMessage(value)
|
||||||
|
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Ref to store the sendMessage function for use in callbacks
|
// Ref to store the sendMessage function for use in callbacks
|
||||||
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
||||||
|
|
||||||
@@ -590,7 +601,7 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSession = sessionManager.currentSession
|
const currentSession = sessionManager.currentSession
|
||||||
if (currentSession && currentSession.messages.length > 0) {
|
if (currentSession) {
|
||||||
// Restore from session manager (IndexedDB)
|
// Restore from session manager (IndexedDB)
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(currentSession)
|
syncUIWithSession(currentSession)
|
||||||
@@ -627,7 +638,7 @@ export default function ChatPanel({
|
|||||||
lastSyncedSessionIdRef.current = newSessionId
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
// Sync UI with new session
|
// Sync UI with new session
|
||||||
if (newSession && newSession.messages.length > 0) {
|
if (newSession) {
|
||||||
justLoadedSessionRef.current = true
|
justLoadedSessionRef.current = true
|
||||||
syncUIWithSession(newSession)
|
syncUIWithSession(newSession)
|
||||||
} else if (!newSession) {
|
} else if (!newSession) {
|
||||||
@@ -682,7 +693,7 @@ export default function ChatPanel({
|
|||||||
// Debounce: save after 1 second of no changes
|
// Debounce: save after 1 second of no changes
|
||||||
localStorageDebounceRef.current = setTimeout(async () => {
|
localStorageDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0 || hasDiagramNow) {
|
||||||
const sessionData = await buildSessionData({
|
const sessionData = await buildSessionData({
|
||||||
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||||
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||||
@@ -704,6 +715,7 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
chartXML,
|
||||||
messages,
|
messages,
|
||||||
status,
|
status,
|
||||||
sessionIsAvailable,
|
sessionIsAvailable,
|
||||||
@@ -734,7 +746,8 @@ export default function ChatPanel({
|
|||||||
const handleVisibilityChange = async () => {
|
const handleVisibilityChange = async () => {
|
||||||
if (
|
if (
|
||||||
document.visibilityState === "hidden" &&
|
document.visibilityState === "hidden" &&
|
||||||
messagesRef.current.length > 0
|
(messagesRef.current.length > 0 ||
|
||||||
|
isRealDiagram(chartXMLRef.current))
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Attempt to save session - browser may not wait for completion
|
// Attempt to save session - browser may not wait for completion
|
||||||
@@ -960,6 +973,25 @@ export default function ChatPanel({
|
|||||||
pathname,
|
pathname,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Handle sending a template directly (called from TemplatePanel)
|
||||||
|
const handleSendTemplate = useCallback(
|
||||||
|
async (template: { prompt: string }) => {
|
||||||
|
flushSync(() => {
|
||||||
|
setInput(template.prompt)
|
||||||
|
setFiles([])
|
||||||
|
setUrlData(new Map())
|
||||||
|
})
|
||||||
|
|
||||||
|
const formElement = document.getElementById(
|
||||||
|
"chat-form",
|
||||||
|
) as HTMLFormElement | null
|
||||||
|
if (formElement) {
|
||||||
|
formElement.requestSubmit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setInput, setFiles, setUrlData],
|
||||||
|
)
|
||||||
|
|
||||||
const handleInputChange = (
|
const handleInputChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -1037,7 +1069,7 @@ export default function ChatPanel({
|
|||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
{
|
{
|
||||||
body: { xml, previousXml, sessionId },
|
body: { xml, previousXml, sessionId, customSystemMessage },
|
||||||
headers: {
|
headers: {
|
||||||
"x-access-code": config.accessCode,
|
"x-access-code": config.accessCode,
|
||||||
...(config.aiProvider && {
|
...(config.aiProvider && {
|
||||||
@@ -1363,6 +1395,8 @@ export default function ChatPanel({
|
|||||||
loadedMessageIdsRef={loadedMessageIdsRef}
|
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||||
validationStates={validationStates}
|
validationStates={validationStates}
|
||||||
onImproveWithSuggestions={handleImproveWithSuggestions}
|
onImproveWithSuggestions={handleImproveWithSuggestions}
|
||||||
|
onSendTemplate={handleSendTemplate}
|
||||||
|
currentInput={input}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1415,6 +1449,8 @@ export default function ChatPanel({
|
|||||||
onMinimalStyleChange={setMinimalStyle}
|
onMinimalStyleChange={setMinimalStyle}
|
||||||
vlmValidationEnabled={vlmValidationEnabled}
|
vlmValidationEnabled={vlmValidationEnabled}
|
||||||
onVlmValidationChange={handleVlmValidationChange}
|
onVlmValidationChange={handleVlmValidationChange}
|
||||||
|
customSystemMessage={customSystemMessage}
|
||||||
|
onCustomSystemMessageChange={handleCustomSystemMessageChange}
|
||||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { TemplatePanel } from "@/components/chat/TemplatePanel"
|
||||||
import ExamplePanel from "@/components/chat-example-panel"
|
import ExamplePanel from "@/components/chat-example-panel"
|
||||||
import Image from "@/components/image-with-basepath"
|
import Image from "@/components/image-with-basepath"
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import type { Template } from "@/lib/template-storage"
|
||||||
|
|
||||||
interface SessionMetadata {
|
interface SessionMetadata {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
|
|||||||
onDeleteSession?: (id: string) => void
|
onDeleteSession?: (id: string) => void
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
dict: {
|
dict: {
|
||||||
sessionHistory?: {
|
sessionHistory?: {
|
||||||
recentChats?: string
|
recentChats?: string
|
||||||
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
|
|||||||
deleteTitle?: string
|
deleteTitle?: string
|
||||||
deleteDescription?: string
|
deleteDescription?: string
|
||||||
}
|
}
|
||||||
|
templates?: {
|
||||||
|
title?: string
|
||||||
|
myTemplates?: string
|
||||||
|
}
|
||||||
examples?: {
|
examples?: {
|
||||||
quickExamples?: string
|
quickExamples?: string
|
||||||
}
|
}
|
||||||
@@ -75,33 +84,73 @@ function formatSessionDate(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPanelVisibility() {
|
||||||
|
if (typeof window === "undefined")
|
||||||
|
return { recentChats: true, myTemplates: true, quickExamples: true }
|
||||||
|
return {
|
||||||
|
recentChats:
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||||
|
myTemplates:
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||||
|
quickExamples:
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatLobby({
|
export function ChatLobby({
|
||||||
sessions,
|
sessions,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
dict,
|
dict,
|
||||||
}: ChatLobbyProps) {
|
}: ChatLobbyProps) {
|
||||||
// Track whether examples section is expanded (collapsed by default when there's history)
|
const [templatesExpanded, setTemplatesExpanded] = useState(true)
|
||||||
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
const [examplesExpanded, setExamplesExpanded] = useState(true)
|
||||||
// Delete confirmation dialog state
|
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||||
// Search filter for history
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
|
// Listen for panel visibility changes from settings
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setPanelVisibility(getPanelVisibility())
|
||||||
|
window.addEventListener("panelVisibilityChange", handler)
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("panelVisibilityChange", handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const hasHistory = sessions.length > 0
|
const hasHistory = sessions.length > 0
|
||||||
|
|
||||||
if (!hasHistory) {
|
if (!hasHistory) {
|
||||||
// Show full examples when no history
|
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
|
||||||
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{panelVisibility.myTemplates && (
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{panelVisibility.quickExamples && (
|
||||||
|
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
|
||||||
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show history + collapsible examples when there are sessions
|
// Show history + collapsible examples when there are sessions
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
{/* Recent Chats Section */}
|
{/* Recent Chats Section */}
|
||||||
|
{panelVisibility.recentChats && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
@@ -145,7 +194,10 @@ export function ChatLobby({
|
|||||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
onClick={() => onSelectSession(session.id)}
|
onClick={() => onSelectSession(session.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSelectSession(session.id)
|
onSelectSession(session.id)
|
||||||
}
|
}
|
||||||
@@ -206,8 +258,39 @@ export function ChatLobby({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collapsible Examples Section */}
|
{/* Collapsible My Templates Section */}
|
||||||
|
{panelVisibility.myTemplates && (
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatesExpanded(!templatesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.templates?.myTemplates || "My Templates"}
|
||||||
|
</span>
|
||||||
|
{templatesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{templatesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TemplatePanel
|
||||||
|
setInput={setInput}
|
||||||
|
onSendTemplate={onSendTemplate}
|
||||||
|
currentInput={currentInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible Quick Examples Section */}
|
||||||
|
{panelVisibility.quickExamples && (
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,6 +316,7 @@ export function ChatLobby({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|||||||
203
components/chat/TemplateCreateDialog.tsx
Normal file
203
components/chat/TemplateCreateDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Plus } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
type TemplateCreateInput,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateCreateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSuccess: () => void
|
||||||
|
initialPrompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateCreateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
initialPrompt = "",
|
||||||
|
}: TemplateCreateDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset form when dialog opens with the latest initialPrompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setPrompt(initialPrompt)
|
||||||
|
setPinned(false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [open, initialPrompt])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: TemplateCreateInput = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await createTemplate(input)
|
||||||
|
if (template) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create template:", err)
|
||||||
|
setError(dict.templates.createFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
{dict.templates.createTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.createDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prompt" className="text-foreground">
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-foreground">
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.templates.createButton}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
components/chat/TemplateEditDialog.tsx
Normal file
215
components/chat/TemplateEditDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Bookmark, Edit2 } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import { type Template, updateTemplate } from "@/lib/template-storage"
|
||||||
|
|
||||||
|
interface TemplateEditDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
template: Template | null
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
template,
|
||||||
|
onSuccess,
|
||||||
|
}: TemplateEditDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [pinned, setPinned] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Populate form when template changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (template) {
|
||||||
|
setTitle(template.title || "")
|
||||||
|
setDescription(template.description || "")
|
||||||
|
setPrompt(template.prompt || "")
|
||||||
|
setPinned(template.pinned || false)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
onOpenChange(newOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const trimmedPrompt = prompt.trim()
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
setError(dict.templates.promptRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
title: title.trim() || template.title,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateTemplate(template.id, updates)
|
||||||
|
if (updated) {
|
||||||
|
onSuccess()
|
||||||
|
onOpenChange(false)
|
||||||
|
} else {
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update template:", err)
|
||||||
|
setError(dict.templates.updateFailed)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] overflow-hidden">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Edit2 className="w-5 h-5" />
|
||||||
|
{dict.templates.editTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.templates.editDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Prompt field - required */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-prompt"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.promptLabel}
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={dict.templates.promptPlaceholder}
|
||||||
|
className="min-h-[100px] resize-none break-words"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-title"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.titleLabel}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={dict.templates.titlePlaceholder}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.titleHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description field - optional */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-description"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
{dict.templates.descriptionLabel}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
dict.templates.descriptionPlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[60px] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pinned switch */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-pinned"
|
||||||
|
className="flex items-center gap-2 text-foreground"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-4 h-4" />
|
||||||
|
{dict.templates.pinnedLabel}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.templates.pinnedHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="edit-pinned"
|
||||||
|
checked={pinned}
|
||||||
|
onCheckedChange={setPinned}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? dict.common.loading
|
||||||
|
: dict.common.save}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
622
components/chat/TemplatePanel.tsx
Normal file
622
components/chat/TemplatePanel.tsx
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
Edit2,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
import {
|
||||||
|
deleteTemplate,
|
||||||
|
duplicateTemplate,
|
||||||
|
exportTemplates,
|
||||||
|
getAllTemplates,
|
||||||
|
importTemplates,
|
||||||
|
incrementClickCount,
|
||||||
|
incrementRunCount,
|
||||||
|
searchTemplates,
|
||||||
|
type Template,
|
||||||
|
updateTemplate,
|
||||||
|
validateImportData,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
import { TemplateCreateDialog } from "./TemplateCreateDialog"
|
||||||
|
import { TemplateEditDialog } from "./TemplateEditDialog"
|
||||||
|
|
||||||
|
interface TemplatePanelProps {
|
||||||
|
setInput: (input: string) => void
|
||||||
|
onSendTemplate?: (template: Template) => void
|
||||||
|
currentInput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(timestamp: number, neverUsedText: string): string {
|
||||||
|
if (!timestamp) return neverUsedText
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = now - timestamp
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
|
||||||
|
if (diffMins < 1) return rtf.format(0, "minute")
|
||||||
|
if (diffMins < 60) return rtf.format(-diffMins, "minute")
|
||||||
|
if (diffHours < 24) return rtf.format(-diffHours, "hour")
|
||||||
|
if (diffDays < 7) return rtf.format(-diffDays, "day")
|
||||||
|
} catch {
|
||||||
|
// Fallback if Intl.RelativeTimeFormat is not available
|
||||||
|
if (diffMins < 1) return "<1m ago"
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatePanel({
|
||||||
|
setInput,
|
||||||
|
onSendTemplate,
|
||||||
|
currentInput = "",
|
||||||
|
}: TemplatePanelProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
|
||||||
|
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [importMessage, setImportMessage] = useState<{
|
||||||
|
type: "success" | "error"
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async () => {
|
||||||
|
const result = await getAllTemplates()
|
||||||
|
setTemplates(result)
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filter templates by search query
|
||||||
|
const filteredTemplates = searchQuery.trim()
|
||||||
|
? searchTemplates(templates, searchQuery)
|
||||||
|
: templates
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates()
|
||||||
|
}, [loadTemplates])
|
||||||
|
|
||||||
|
const handleCreateSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditSuccess = () => {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (template: Template) => {
|
||||||
|
setTemplateToEdit(template)
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDuplicate = async (template: Template) => {
|
||||||
|
const duplicated = await duplicateTemplate(
|
||||||
|
template.id,
|
||||||
|
dict.templates.copySuffix || "(copy)",
|
||||||
|
)
|
||||||
|
if (duplicated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClick = (template: Template) => {
|
||||||
|
setTemplateToDelete(template)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!templateToDelete) return
|
||||||
|
const success = await deleteTemplate(templateToDelete.id)
|
||||||
|
if (success) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setTemplateToDelete(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async (template: Template) => {
|
||||||
|
const updated = await updateTemplate(template.id, {
|
||||||
|
pinned: !template.pinned,
|
||||||
|
})
|
||||||
|
if (updated) {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle template card click - send directly or show confirmation
|
||||||
|
const handleTemplateClick = async (template: Template) => {
|
||||||
|
// If there's unsent content in the input, show confirmation dialog
|
||||||
|
if (currentInput.trim()) {
|
||||||
|
setTemplateToSend(template)
|
||||||
|
setConfirmSendDialogOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No unsent content, send directly
|
||||||
|
await sendTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually send the template
|
||||||
|
const sendTemplate = async (template: Template) => {
|
||||||
|
// Increment click count only when actually sending
|
||||||
|
await incrementClickCount(template.id)
|
||||||
|
if (onSendTemplate) {
|
||||||
|
// Increment run count and update lastUsedAt
|
||||||
|
await incrementRunCount(template.id)
|
||||||
|
// Reload to show updated stats
|
||||||
|
loadTemplates()
|
||||||
|
// Call the send callback
|
||||||
|
onSendTemplate(template)
|
||||||
|
} else {
|
||||||
|
// Fallback: just fill the input if no send callback provided
|
||||||
|
setInput(template.prompt)
|
||||||
|
}
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle confirmation dialog - user confirmed to send template
|
||||||
|
const handleConfirmSend = async () => {
|
||||||
|
if (!templateToSend) return
|
||||||
|
await sendTemplate(templateToSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel - close dialog without sending
|
||||||
|
const handleCancelSend = () => {
|
||||||
|
setConfirmSendDialogOpen(false)
|
||||||
|
setTemplateToSend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export templates to JSON file
|
||||||
|
const handleExport = () => {
|
||||||
|
if (templates.length === 0) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.exportEmpty || "No templates to export",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportData = exportTemplates(templates)
|
||||||
|
const json = JSON.stringify(exportData, null, 2)
|
||||||
|
const blob = new Blob([json], { type: "application/json" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.exportSuccess.replace(
|
||||||
|
"{count}",
|
||||||
|
String(templates.length),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates from JSON file
|
||||||
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
dict.templates.importNoFile || "Please select a JSON file",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
|
// Validate import data
|
||||||
|
const validation = validateImportData(data)
|
||||||
|
if (!validation.valid) {
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
validation.error || "Invalid data",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import templates with dedup-append strategy
|
||||||
|
const result = await importTemplates(data.templates, templates)
|
||||||
|
|
||||||
|
// Reload template list
|
||||||
|
await loadTemplates()
|
||||||
|
|
||||||
|
setImportMessage({
|
||||||
|
type: "success",
|
||||||
|
text: dict.templates.importSuccess
|
||||||
|
.replace("{imported}", String(result.imported))
|
||||||
|
.replace("{skipped}", String(result.skipped)),
|
||||||
|
})
|
||||||
|
setTimeout(() => setImportMessage(null), 5000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to import templates:", error)
|
||||||
|
setImportMessage({
|
||||||
|
type: "error",
|
||||||
|
text: dict.templates.importFailed.replace(
|
||||||
|
"{error}",
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state: no templates at all
|
||||||
|
if (!loading && templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{dict.templates.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
||||||
|
{dict.templates.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<FileText className="w-8 h-8 text-primary/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">
|
||||||
|
{dict.templates.emptyTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
|
||||||
|
{dict.templates.emptyDescription}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{dict.templates.createFirst}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template list
|
||||||
|
return (
|
||||||
|
<div className="py-2 px-2 animate-fade-in">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={dict.templates.searchPlaceholder}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.createButton}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={templates.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={dict.templates.exportTemplates}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.exportTemplates}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
title={dict.templates.importTemplates}
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
{dict.templates.importTemplates}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import message */}
|
||||||
|
{importMessage && (
|
||||||
|
<div
|
||||||
|
className={`text-xs px-3 py-2 rounded-lg ${
|
||||||
|
importMessage.type === "success"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{importMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loading
|
||||||
|
? // Loading skeleton
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`skeleton-${String(i)}`}
|
||||||
|
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded w-2/3" />
|
||||||
|
<div className="h-3 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: filteredTemplates.length === 0
|
||||||
|
? // Search empty state
|
||||||
|
!loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 px-4">
|
||||||
|
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{dict.templates.searchNoResults}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: filteredTemplates.map((template) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key === " "
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleTemplateClick(template)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{template.title}
|
||||||
|
</div>
|
||||||
|
{template.pinned && (
|
||||||
|
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{template.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{template.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions and stats */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
|
||||||
|
{template.runCount > 0
|
||||||
|
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
|
||||||
|
: dict.templates.neverUsed}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTogglePin(template)
|
||||||
|
}}
|
||||||
|
className={`p-1.5 rounded-lg transition-all ${
|
||||||
|
template.pinned
|
||||||
|
? "text-primary hover:text-primary/80 hover:bg-primary/10"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
template.pinned
|
||||||
|
? dict.templates
|
||||||
|
.unpin || "Unpin"
|
||||||
|
: dict.templates.pin ||
|
||||||
|
"Pin"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bookmark
|
||||||
|
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={dict.common.edit}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDuplicate(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
|
||||||
|
title={
|
||||||
|
dict.templates.duplicate ||
|
||||||
|
"Duplicate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteClick(template)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TemplateCreateDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
template={templateToEdit}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.deleteTitle ||
|
||||||
|
"Delete this template?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.deleteDescription ||
|
||||||
|
"This will permanently delete this template. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
|
||||||
|
>
|
||||||
|
{dict.common.delete}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Confirm Send Dialog - when there's unsent input */}
|
||||||
|
<AlertDialog
|
||||||
|
open={confirmSendDialogOpen}
|
||||||
|
onOpenChange={setConfirmSendDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.templates.confirmSendTitle ||
|
||||||
|
"Replace current input?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.templates.confirmSendDescription ||
|
||||||
|
"You have unsent content in the input. Sending this template will replace it."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelSend}>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirmSend}>
|
||||||
|
{dict.templates.confirmSendButton ||
|
||||||
|
"Send Template"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -195,7 +195,22 @@ export function ToolCallCard({
|
|||||||
{input && isExpanded && (
|
{input && isExpanded && (
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
{typeof input === "object" && input.xml ? (
|
{typeof input === "object" && input.xml ? (
|
||||||
|
state === "input-streaming" ||
|
||||||
|
state === "input-available" ? (
|
||||||
|
<pre
|
||||||
|
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
"var(--font-mono), ui-monospace, monospace",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{input.xml}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
)
|
||||||
) : typeof input === "object" &&
|
) : typeof input === "object" &&
|
||||||
input.operations &&
|
input.operations &&
|
||||||
Array.isArray(input.operations) ? (
|
Array.isArray(input.operations) ? (
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
|||||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
import {
|
||||||
|
PROVIDER_INFO,
|
||||||
|
PROVIDER_LOGO_MAP,
|
||||||
|
SUGGESTED_MODELS,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelConfigDialogProps {
|
interface ModelConfigDialogProps {
|
||||||
@@ -65,24 +69,6 @@ interface ModelConfigDialogProps {
|
|||||||
|
|
||||||
type ValidationStatus = "idle" | "validating" | "success" | "error"
|
type ValidationStatus = "idle" | "validating" | "success" | "error"
|
||||||
|
|
||||||
// Map provider names to models.dev logo names
|
|
||||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|
||||||
openai: "openai",
|
|
||||||
anthropic: "anthropic",
|
|
||||||
google: "google",
|
|
||||||
azure: "azure",
|
|
||||||
bedrock: "amazon-bedrock",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
siliconflow: "siliconflow",
|
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible
|
|
||||||
gateway: "vercel",
|
|
||||||
edgeone: "tencent-cloud",
|
|
||||||
vertexai: "google",
|
|
||||||
doubao: "bytedance",
|
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider logo component
|
// Provider logo component
|
||||||
function ProviderLogo({
|
function ProviderLogo({
|
||||||
provider,
|
provider,
|
||||||
@@ -527,7 +513,7 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider Details (Right Panel) */}
|
{/* Provider Details (Right Panel) */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
|
<div className="flex-1 min-w-0 flex flex-col overflow-auto scrollbar-thin">
|
||||||
{selectedProvider ? (
|
{selectedProvider ? (
|
||||||
<ScrollArea className="flex-1" ref={scrollRef}>
|
<ScrollArea className="flex-1" ref={scrollRef}>
|
||||||
<div className="p-6 space-y-8">
|
<div className="p-6 space-y-8">
|
||||||
@@ -1255,6 +1241,16 @@ export function ModelConfigDialog({
|
|||||||
}
|
}
|
||||||
className="h-9 rounded-xl font-mono text-xs"
|
className="h-9 rounded-xl font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
{selectedProvider.provider ===
|
||||||
|
"minimax" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{
|
||||||
|
dict
|
||||||
|
.modelConfig
|
||||||
|
.minimaxBaseUrlHint
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import {
|
|||||||
} from "@/components/ai-elements/model-selector"
|
} from "@/components/ai-elements/model-selector"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
import {
|
||||||
|
type FlattenedModel,
|
||||||
|
PROVIDER_LOGO_MAP,
|
||||||
|
} from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
@@ -39,24 +42,6 @@ interface ModelSelectorProps {
|
|||||||
showUnvalidatedModels?: boolean
|
showUnvalidatedModels?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map our provider names to models.dev logo names
|
|
||||||
const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|
||||||
openai: "openai",
|
|
||||||
anthropic: "anthropic",
|
|
||||||
google: "google",
|
|
||||||
azure: "azure",
|
|
||||||
bedrock: "amazon-bedrock",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
siliconflow: "siliconflow",
|
|
||||||
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
|
|
||||||
gateway: "vercel",
|
|
||||||
edgeone: "tencent-cloud",
|
|
||||||
vertexai: "google",
|
|
||||||
doubao: "bytedance",
|
|
||||||
modelscope: "modelscope",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
function groupModelsByProvider(
|
function groupModelsByProvider(
|
||||||
models: FlattenedModel[],
|
models: FlattenedModel[],
|
||||||
@@ -214,7 +199,7 @@ export function ModelSelector({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||||
<ModelSelectorEmpty>
|
<ModelSelectorEmpty>
|
||||||
{displayModels.length === 0 &&
|
{displayModels.length === 0 &&
|
||||||
models.length > 0
|
models.length > 0
|
||||||
|
|||||||
@@ -23,9 +23,22 @@ export function QuotaLimitToast({
|
|||||||
}: QuotaLimitToastProps) {
|
}: QuotaLimitToastProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isTokenLimit = type === "token"
|
const isTokenLimit = type === "token"
|
||||||
|
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||||
const formatNumber = (n: number) =>
|
const formatNumber = (n: number) =>
|
||||||
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
|
||||||
|
|
||||||
|
const quotaMessage = isTokenLimit
|
||||||
|
? isSelfHosted
|
||||||
|
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
|
||||||
|
: dict.quota.messageToken
|
||||||
|
: isSelfHosted
|
||||||
|
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
|
||||||
|
: dict.quota.messageApi
|
||||||
|
|
||||||
|
const tipHtml = isSelfHosted
|
||||||
|
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
|
||||||
|
: dict.quota.tip
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -71,19 +84,24 @@ export function QuotaLimitToast({
|
|||||||
</div>
|
</div>
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
|
||||||
<p>
|
<p>{quotaMessage}</p>
|
||||||
{isTokenLimit
|
{!isSelfHosted && (
|
||||||
? dict.quota.messageToken
|
|
||||||
: dict.quota.messageApi}
|
|
||||||
</p>
|
|
||||||
<p
|
<p
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: formatMessage(dict.quota.doubaoSponsorship, {
|
__html: formatMessage(
|
||||||
link: "https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
|
dict.quota.doubaoSponsorship,
|
||||||
}),
|
{
|
||||||
|
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: tipHtml,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
|
|
||||||
<p>{dict.quota.reset}</p>
|
<p>{dict.quota.reset}</p>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
@@ -101,6 +119,8 @@ export function QuotaLimitToast({
|
|||||||
{dict.quota.configModel}
|
{dict.quota.configModel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!isSelfHosted && (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
href="https://github.com/DayuanJiang/next-ai-draw-io"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -119,6 +139,8 @@ export function QuotaLimitToast({
|
|||||||
<Coffee className="w-3.5 h-3.5" />
|
<Coffee className="w-3.5 h-3.5" />
|
||||||
{dict.quota.sponsor}
|
{dict.quota.sponsor}
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
export type ExportFormat = "drawio" | "png" | "svg"
|
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
interface SaveDialogProps {
|
interface SaveDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -74,6 +74,11 @@ export function SaveDialog({
|
|||||||
label: dict.save.formats.svg,
|
label: dict.save.formats.svg,
|
||||||
extension: ".svg",
|
extension: ".svg",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "xmlsvg" as const,
|
||||||
|
label: dict.save.formats.xmlsvg,
|
||||||
|
extension: ".drawio.svg",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||||
@@ -71,6 +72,8 @@ interface SettingsDialogProps {
|
|||||||
vlmValidationEnabled?: boolean
|
vlmValidationEnabled?: boolean
|
||||||
onVlmValidationChange?: (value: boolean) => void
|
onVlmValidationChange?: (value: boolean) => void
|
||||||
onOpenModelConfig?: () => void
|
onOpenModelConfig?: () => void
|
||||||
|
customSystemMessage?: string
|
||||||
|
onCustomSystemMessageChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||||
@@ -95,6 +98,8 @@ function SettingsContent({
|
|||||||
vlmValidationEnabled = false,
|
vlmValidationEnabled = false,
|
||||||
onVlmValidationChange = () => {},
|
onVlmValidationChange = () => {},
|
||||||
onOpenModelConfig,
|
onOpenModelConfig,
|
||||||
|
customSystemMessage = "",
|
||||||
|
onCustomSystemMessageChange = () => {},
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -109,6 +114,20 @@ 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("")
|
||||||
@@ -159,6 +178,17 @@ function SettingsContent({
|
|||||||
)
|
)
|
||||||
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
setSendShortcut(storedSendShortcut || "ctrl-enter")
|
||||||
|
|
||||||
|
setShowRecentChats(
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
|
||||||
|
)
|
||||||
|
setShowMyTemplates(
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
|
||||||
|
)
|
||||||
|
setShowQuickExamples(
|
||||||
|
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
|
||||||
|
"false",
|
||||||
|
)
|
||||||
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
// Load proxy settings (Electron only)
|
// Load proxy settings (Electron only)
|
||||||
@@ -274,7 +304,7 @@ function SettingsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent className="sm:max-w-lg p-0 gap-0">
|
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DialogHeader className="px-6 pt-6 pb-4">
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
<DialogTitle>{dict.settings.title}</DialogTitle>
|
<DialogTitle>{dict.settings.title}</DialogTitle>
|
||||||
@@ -284,7 +314,7 @@ function SettingsContent({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{/* API Keys & Models */}
|
{/* API Keys & Models */}
|
||||||
{onOpenModelConfig && (
|
{onOpenModelConfig && (
|
||||||
@@ -438,6 +468,63 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Panel Visibility */}
|
||||||
|
<SettingItem
|
||||||
|
label={dict.settings.panelVisibility}
|
||||||
|
description={dict.settings.panelVisibilityDescription}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-recent-chats"
|
||||||
|
checked={showRecentChats}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showRecentChats,
|
||||||
|
v,
|
||||||
|
setShowRecentChats,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showRecentChats}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-my-templates"
|
||||||
|
checked={showMyTemplates}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showMyTemplates,
|
||||||
|
v,
|
||||||
|
setShowMyTemplates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showMyTemplates}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Switch
|
||||||
|
id="show-quick-examples"
|
||||||
|
checked={showQuickExamples}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
handlePanelToggle(
|
||||||
|
STORAGE_KEYS.showQuickExamples,
|
||||||
|
v,
|
||||||
|
setShowQuickExamples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.showQuickExamples}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{/* VLM Diagram Validation */}
|
{/* VLM Diagram Validation */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.diagramValidation}
|
label={dict.settings.diagramValidation}
|
||||||
@@ -457,6 +544,33 @@ function SettingsContent({
|
|||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Custom System Message */}
|
||||||
|
<div className="py-4 space-y-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="custom-system-message"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{dict.settings.customSystemMessage}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{dict.settings.customSystemMessageDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="custom-system-message"
|
||||||
|
value={customSystemMessage}
|
||||||
|
onChange={(e) =>
|
||||||
|
onCustomSystemMessageChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
dict.settings.customSystemMessagePlaceholder
|
||||||
|
}
|
||||||
|
className="min-h-[80px] max-h-[160px] text-sm"
|
||||||
|
maxLength={5000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Send Shortcut */}
|
{/* Send Shortcut */}
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={dict.settings.sendShortcut}
|
label={dict.settings.sendShortcut}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ interface DiagramContextType {
|
|||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
resolverRef: React.Ref<((value: string) => void) | null>
|
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>
|
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||||
handleDiagramExport: (data: any) => void
|
handleDiagramExport: (data: any) => void
|
||||||
|
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||||
clearDiagram: () => void
|
clearDiagram: () => void
|
||||||
saveDiagramToFile: (
|
saveDiagramToFile: (
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||||
// Track if we're expecting an export for history (user-initiated)
|
// Track if we're expecting an export for history (user-initiated)
|
||||||
const expectHistoryExportRef = useRef<boolean>(false)
|
const expectHistoryExportRef = useRef<boolean>(false)
|
||||||
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
|
||||||
// Track latest chartXML for restoration after remount
|
// Track latest chartXML for restoration after remount
|
||||||
const chartXMLRef = useRef<string>("")
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
@@ -66,6 +65,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
setIsDrawioReady(true)
|
setIsDrawioReady(true)
|
||||||
|
// Restore diagram after remount (e.g., theme/UI change)
|
||||||
|
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||||
|
drawioRef.current.load({ xml: chartXMLRef.current })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDrawioReady = () => {
|
const resetDrawioReady = () => {
|
||||||
@@ -78,24 +81,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
|
|
||||||
if (!isDrawioReady) {
|
|
||||||
hasDiagramRestoredRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only restore once per ready cycle
|
|
||||||
if (hasDiagramRestoredRef.current) return
|
|
||||||
hasDiagramRestoredRef.current = true
|
|
||||||
|
|
||||||
// Restore diagram from ref if we have one
|
|
||||||
const xmlToRestore = chartXMLRef.current
|
|
||||||
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
|
||||||
drawioRef.current.load({ xml: xmlToRestore })
|
|
||||||
}
|
|
||||||
}, [isDrawioReady])
|
|
||||||
|
|
||||||
// Track if we're expecting an export for file save (stores raw export data)
|
// Track if we're expecting an export for file save (stores raw export data)
|
||||||
const saveResolverRef = useRef<{
|
const saveResolverRef = useRef<{
|
||||||
resolver: ((data: string) => void) | null
|
resolver: ((data: string) => void) | null
|
||||||
@@ -234,7 +219,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
saveResolverRef.current = { resolver: null, format: null }
|
saveResolverRef.current = { resolver: null, format: null }
|
||||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||||
if (format === "png" || format === "svg") {
|
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||||
|
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +253,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||||
|
if (!data?.xml) return
|
||||||
|
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||||
|
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||||
|
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChartXML(data.xml)
|
||||||
|
}
|
||||||
|
|
||||||
const clearDiagram = () => {
|
const clearDiagram = () => {
|
||||||
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
|
||||||
@@ -287,7 +283,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map format to draw.io export format
|
// Map format to draw.io export format
|
||||||
const drawioFormat = format === "drawio" ? "xmlsvg" : format
|
const drawioFormat =
|
||||||
|
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
|
||||||
|
|
||||||
// Set up the resolver before triggering export
|
// Set up the resolver before triggering export
|
||||||
saveResolverRef.current = {
|
saveResolverRef.current = {
|
||||||
@@ -311,8 +308,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
extension = ".png"
|
extension = ".png"
|
||||||
|
} else if (format === "xmlsvg") {
|
||||||
|
// Editable SVG: pass data URL directly (like PNG)
|
||||||
|
fileContent = exportData
|
||||||
|
mimeType = "image/svg+xml"
|
||||||
|
extension = ".drawio.svg"
|
||||||
} else {
|
} else {
|
||||||
// SVG format
|
// SVG format (view-only)
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
mimeType = "image/svg+xml"
|
mimeType = "image/svg+xml"
|
||||||
extension = ".svg"
|
extension = ".svg"
|
||||||
@@ -391,6 +393,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolverRef,
|
resolverRef,
|
||||||
drawioRef,
|
drawioRef,
|
||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
|
handleDiagramAutoSave,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
getThumbnailSvg,
|
getThumbnailSvg,
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
|
||||||
|
|
||||||
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
|
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
|
||||||
|
|
||||||
|
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
@@ -28,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)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [在线试用](#在线试用)
|
- [在线试用](#在线试用)
|
||||||
@@ -54,31 +56,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>动画Transformer连接器</strong><br />
|
<strong>动画Transformer连接器</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCP架构图</strong><br />
|
<strong>RAG技术图</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWS架构图</strong><br />
|
<strong>React和AWS认证流程</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
|
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azure架构图</strong><br />
|
<strong>开放式创新</strong><br />
|
||||||
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
|
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫咪素描</strong><br />
|
<strong>猫咪素描</strong><br />
|
||||||
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,9 +98,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||||
|
|
||||||
## MCP服务器(预览)
|
## MCP服务器
|
||||||
|
|
||||||
> **预览功能**:此功能为实验性功能,可能不稳定。
|
|
||||||
|
|
||||||
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ npm run dev
|
|||||||
|
|
||||||
## 多提供商支持
|
## 多提供商支持
|
||||||
|
|
||||||
- [字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
||||||
- AWS Bedrock(默认)
|
- AWS Bedrock(默认)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -236,7 +236,7 @@ npm run dev
|
|||||||
|
|
||||||
## 支持与联系
|
## 支持与联系
|
||||||
|
|
||||||
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
|
||||||
|
|
||||||
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### 豆包 (字节跳动火山引擎)
|
### 豆包 (字节跳动火山引擎)
|
||||||
|
|
||||||
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
|
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -207,6 +207,85 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
|
||||||
|
|
||||||
|
### MiniMax
|
||||||
|
|
||||||
|
MiniMax 支持两种 API 格式:
|
||||||
|
- **Anthropic 兼容**(`/anthropic` 端点)— 推荐,支持 interleaved thinking
|
||||||
|
- **OpenAI 兼容**(`/v1` 端点)— 标准 OpenAI 聊天补全格式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MINIMAX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=MiniMax-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
可选配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 中国大陆版,Anthropic 兼容(默认)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# 中国大陆版,OpenAI 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# 国际版,Anthropic 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# 国际版,OpenAI 兼容
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (智谱 AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (阿里云通义千问)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (月之暗面 Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (七牛云)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
## 自动检测
|
## 自动检测
|
||||||
|
|
||||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||||
@@ -214,7 +293,7 @@ AI_MODEL=openai/gpt-4o
|
|||||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## 服务端多模型配置
|
## 服务端多模型配置
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
|
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -222,6 +222,85 @@ Model format uses `provider/model` syntax:
|
|||||||
|
|
||||||
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
|
||||||
|
|
||||||
|
### MiniMax
|
||||||
|
|
||||||
|
MiniMax supports two API formats:
|
||||||
|
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
|
||||||
|
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MINIMAX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=MiniMax-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# China mainland, Anthropic-compatible (default)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# China mainland, OpenAI-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# International, Anthropic-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# International, OpenAI-compatible
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (Zhipu AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (Alibaba Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (Qiniu Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
## Auto-Detection
|
## Auto-Detection
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||||
@@ -229,7 +308,7 @@ If you only configure **one** provider's API key, the system will automatically
|
|||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server-Side Multi-Model Configuration
|
## Server-Side Multi-Model Configuration
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
|
||||||
|
|
||||||
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
|
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [目次](#目次)
|
- [目次](#目次)
|
||||||
- [例](#例)
|
- [例](#例)
|
||||||
- [機能](#機能)
|
- [機能](#機能)
|
||||||
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
|
- [MCPサーバー](#mcpサーバー)
|
||||||
- [Claude Code CLI](#claude-code-cli)
|
- [Claude Code CLI](#claude-code-cli)
|
||||||
- [はじめに](#はじめに)
|
- [はじめに](#はじめに)
|
||||||
- [オンラインで試す](#オンラインで試す)
|
- [オンラインで試す](#オンラインで試す)
|
||||||
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" valign="top" align="center">
|
<td colspan="2" valign="top" align="center">
|
||||||
<strong>アニメーションTransformerコネクタ</strong><br />
|
<strong>アニメーションTransformerコネクタ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
|
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
|
||||||
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>GCPアーキテクチャ図</strong><br />
|
<strong>RAG技術ダイアグラム</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
|
||||||
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
|
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>AWSアーキテクチャ図</strong><br />
|
<strong>ReactとAWSによる認証</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
|
||||||
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
|
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>Azureアーキテクチャ図</strong><br />
|
<strong>オープンイノベーション</strong><br />
|
||||||
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
|
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
|
||||||
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
|
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<strong>猫のスケッチ</strong><br />
|
<strong>猫のスケッチ</strong><br />
|
||||||
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
|
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
|
||||||
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -96,9 +96,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||||
|
|
||||||
## MCPサーバー(プレビュー)
|
## MCPサーバー
|
||||||
|
|
||||||
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
|
|
||||||
|
|
||||||
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
|
|
||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- [ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
|
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
|
||||||
- AWS Bedrock(デフォルト)
|
- AWS Bedrock(デフォルト)
|
||||||
- OpenAI
|
- OpenAI
|
||||||
- Anthropic
|
- Anthropic
|
||||||
@@ -237,7 +235,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
## サポート&お問い合わせ
|
## サポート&お問い合わせ
|
||||||
|
|
||||||
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
|
||||||
|
|
||||||
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
### Doubao (ByteDance Volcengine)
|
### Doubao (ByteDance Volcengine)
|
||||||
|
|
||||||
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/newyear-referral?utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOUBAO_API_KEY=your_api_key
|
DOUBAO_API_KEY=your_api_key
|
||||||
@@ -207,6 +207,85 @@ AI_MODEL=openai/gpt-4o
|
|||||||
|
|
||||||
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
|
||||||
|
|
||||||
|
### MiniMax
|
||||||
|
|
||||||
|
MiniMax は 2 つの API 形式をサポートしています:
|
||||||
|
- **Anthropic 互換**(`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
|
||||||
|
- **OpenAI 互換**(`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MINIMAX_API_KEY=your_api_key
|
||||||
|
AI_MODEL=MiniMax-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
オプション設定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 中国大陸版、Anthropic 互換(デフォルト)
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
|
|
||||||
|
# 中国大陸版、OpenAI 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
|
||||||
|
# 国際版、Anthropic 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
|
||||||
|
|
||||||
|
# 国際版、OpenAI 互換
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### GLM (Zhipu AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_API_KEY=your_api_key
|
||||||
|
AI_MODEL=glm-4
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GLM_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (Alibaba Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_API_KEY=your_api_key
|
||||||
|
AI_MODEL=qwen-turbo
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QWEN_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi (Moonshot AI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_API_KEY=your_api_key
|
||||||
|
AI_MODEL=kimi-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIMI_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qiniu (Qiniu Cloud)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_API_KEY=your_api_key
|
||||||
|
AI_MODEL=your_model_id
|
||||||
|
```
|
||||||
|
|
||||||
|
オプションのカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
QINIU_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
## 自動検出
|
## 自動検出
|
||||||
|
|
||||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||||
@@ -214,7 +293,7 @@ AI_MODEL=openai/gpt-4o
|
|||||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
|
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||||
```
|
```
|
||||||
|
|
||||||
## サーバーサイドマルチモデル設定
|
## サーバーサイドマルチモデル設定
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ directories:
|
|||||||
afterPack: ./scripts/afterPack.cjs
|
afterPack: ./scripts/afterPack.cjs
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- dist-electron/**/*
|
- from: dist-electron
|
||||||
- "!node_modules"
|
to: dist-electron
|
||||||
|
filter:
|
||||||
|
- "**/*"
|
||||||
|
- from: .
|
||||||
|
filter:
|
||||||
|
- package.json
|
||||||
|
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
|
|||||||
39
env.example
39
env.example
@@ -1,6 +1,6 @@
|
|||||||
# AI Provider Configuration
|
# AI Provider Configuration
|
||||||
# AI_PROVIDER: Which provider to use
|
# AI_PROVIDER: Which provider to use
|
||||||
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
|
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita
|
||||||
# Default: bedrock
|
# Default: bedrock
|
||||||
AI_PROVIDER=bedrock
|
AI_PROVIDER=bedrock
|
||||||
|
|
||||||
@@ -135,3 +135,40 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Allow private/internal URLs for reverse proxy setups (default: true)
|
# Allow private/internal URLs for reverse proxy setups (default: true)
|
||||||
# Set to "false" to block private IPs, localhost, and internal hostnames
|
# Set to "false" to block private IPs, localhost, and internal hostnames
|
||||||
# ALLOW_PRIVATE_URLS=false
|
# ALLOW_PRIVATE_URLS=false
|
||||||
|
|
||||||
|
# Self-hosted deployment (Optional)
|
||||||
|
# Self-hosted users may implement custom quota-management solutions,
|
||||||
|
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
|
||||||
|
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
|
||||||
|
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
|
||||||
|
# NEXT_PUBLIC_SELFHOSTED=true
|
||||||
|
|
||||||
|
# Minimax Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
|
||||||
|
# MINIMAX_API_KEY=your_minimax_api_key
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland)
|
||||||
|
|
||||||
|
# GLM Configuration (Optional)
|
||||||
|
# Get your API key from: https://open.bigmodel.cn/dev/api
|
||||||
|
# GLM_API_KEY=your_glm_api_key
|
||||||
|
# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default
|
||||||
|
|
||||||
|
# Qwen Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.aliyun.com/product/bailian
|
||||||
|
# QWEN_API_KEY=your_qwen_api_key
|
||||||
|
# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default
|
||||||
|
|
||||||
|
# Kimi Configuration (Optional)
|
||||||
|
# Get your API key from: https://platform.moonshot.cn/
|
||||||
|
# KIMI_API_KEY=your_kimi_api_key
|
||||||
|
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default
|
||||||
|
|
||||||
|
# Qiniu Configuration (Optional)
|
||||||
|
# Get your API key from: https://www.qiniu.com/ai/models
|
||||||
|
# QINIU_API_KEY=your_qiniu_api_key
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createOpenAI, openai } from "@ai-sdk/openai"
|
|||||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||||
import type { ProviderName } from "@/lib/types/model-config"
|
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||||
|
|
||||||
export type { ProviderName }
|
export type { ProviderName }
|
||||||
|
|
||||||
@@ -18,6 +18,43 @@ interface ModelConfig {
|
|||||||
providerOptions?: any
|
providerOptions?: any
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
modelId: string
|
modelId: string
|
||||||
|
provider: ProviderName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers that only support a single system message
|
||||||
|
export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
||||||
|
"minimax",
|
||||||
|
"glm",
|
||||||
|
"qwen",
|
||||||
|
"kimi",
|
||||||
|
"qiniu",
|
||||||
|
"novita",
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize MiniMax base URL for AI SDK compatibility.
|
||||||
|
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
|
||||||
|
*/
|
||||||
|
export function normalizeMiniMaxBaseURL(rawUrl: string): {
|
||||||
|
baseURL: string
|
||||||
|
isAnthropicCompatible: boolean
|
||||||
|
} {
|
||||||
|
const isAnthropicCompatible = rawUrl.includes("/anthropic")
|
||||||
|
let baseURL = rawUrl.replace(/\/$/, "")
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
if (!baseURL.endsWith("/anthropic/v1")) {
|
||||||
|
if (baseURL.endsWith("/anthropic")) {
|
||||||
|
baseURL = `${baseURL}/v1`
|
||||||
|
} else {
|
||||||
|
baseURL = `${baseURL}/anthropic/v1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!baseURL.endsWith("/v1")) {
|
||||||
|
baseURL = `${baseURL}/v1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { baseURL, isAnthropicCompatible }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientOverrides {
|
export interface ClientOverrides {
|
||||||
@@ -57,6 +94,12 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"ollama",
|
"ollama",
|
||||||
"doubao",
|
"doubao",
|
||||||
"modelscope",
|
"modelscope",
|
||||||
|
"glm",
|
||||||
|
"qwen",
|
||||||
|
"qiniu",
|
||||||
|
"kimi",
|
||||||
|
"minimax",
|
||||||
|
"novita",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -474,7 +517,13 @@ function buildProviderOptions(
|
|||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
case "modelscope":
|
case "modelscope":
|
||||||
case "doubao": {
|
case "doubao":
|
||||||
|
case "minimax":
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "kimi":
|
||||||
|
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
|
||||||
@@ -504,6 +553,12 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
|
||||||
doubao: "DOUBAO_API_KEY",
|
doubao: "DOUBAO_API_KEY",
|
||||||
modelscope: "MODELSCOPE_API_KEY",
|
modelscope: "MODELSCOPE_API_KEY",
|
||||||
|
glm: "GLM_API_KEY",
|
||||||
|
qwen: "QWEN_API_KEY",
|
||||||
|
qiniu: "QINIU_API_KEY",
|
||||||
|
kimi: "KIMI_API_KEY",
|
||||||
|
minimax: "MINIMAX_API_KEY",
|
||||||
|
novita: "NOVITA_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1172,9 +1227,70 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "minimax": {
|
||||||
|
const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY")
|
||||||
|
const serverBaseUrl = resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
"MINIMAX_BASE_URL",
|
||||||
|
)
|
||||||
|
const rawBaseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
serverBaseUrl,
|
||||||
|
PROVIDER_INFO.minimax.defaultBaseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rawBaseURL) {
|
||||||
|
throw new Error(
|
||||||
|
"MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseURL, isAnthropicCompatible } =
|
||||||
|
normalizeMiniMaxBaseURL(rawBaseURL)
|
||||||
|
|
||||||
|
if (isAnthropicCompatible) {
|
||||||
|
const minimax = createAnthropic({ apiKey, baseURL })
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
} else {
|
||||||
|
const minimax = createOpenAI({ apiKey, baseURL })
|
||||||
|
model = minimax.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "glm":
|
||||||
|
case "qwen":
|
||||||
|
case "qiniu":
|
||||||
|
case "kimi":
|
||||||
|
case "novita": {
|
||||||
|
const envVar = PROVIDER_ENV_VARS[provider]
|
||||||
|
if (!envVar) {
|
||||||
|
throw new Error(
|
||||||
|
`API key environment variable not defined for provider: ${provider}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const apiKey = resolveApiKey(overrides, envVar)
|
||||||
|
const baseURL = resolveBaseURL(
|
||||||
|
overrides?.apiKey,
|
||||||
|
overrides?.baseUrl,
|
||||||
|
resolveBaseUrlEnv(
|
||||||
|
overrides,
|
||||||
|
`${provider.toUpperCase()}_BASE_URL`,
|
||||||
|
),
|
||||||
|
PROVIDER_INFO[provider]?.defaultBaseUrl,
|
||||||
|
)
|
||||||
|
const customProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = customProvider.chat(modelId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
|
`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`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,7 +1299,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
providerOptions = customProviderOptions
|
providerOptions = customProviderOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
return { model, providerOptions, headers, modelId }
|
return { model, providerOptions, headers, modelId, provider }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1224,21 +1340,39 @@ export function supportsImageInput(modelId: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Moonshot text models (moonshot-v1 series are text-only)
|
||||||
|
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniMax text models (MiniMax-M2.x series are text-only)
|
||||||
|
if (lowerModelId.includes("minimax") && !hasVisionIndicator) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// DeepSeek text models (not vision variants)
|
// DeepSeek text models (not vision variants)
|
||||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qwen text models (not vision variants like qwen-vl)
|
// Qwen text models (not vision variants like qwen-vl)
|
||||||
// qwen3.5-plus is a vision model
|
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
|
||||||
if (
|
if (
|
||||||
lowerModelId.includes("qwen") &&
|
lowerModelId.includes("qwen") &&
|
||||||
!hasVisionIndicator &&
|
!hasVisionIndicator &&
|
||||||
!lowerModelId.includes("qwen3.5-plus")
|
!lowerModelId.includes("qwen3.5")
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GLM text models (not vision variants)
|
||||||
|
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
|
||||||
|
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
|
||||||
|
if (!/[\d.]v/.test(lowerModelId)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default: assume model supports images
|
// Default: assume model supports images
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow",
|
||||||
"modelscope": "ModelScope"
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -71,8 +76,7 @@
|
|||||||
"creativeDescription": "Draw something fun and creative",
|
"creativeDescription": "Draw something fun and creative",
|
||||||
"cachedNote": "Examples are cached for instant response",
|
"cachedNote": "Examples are cached for instant response",
|
||||||
"mcpServer": "MCP Server",
|
"mcpServer": "MCP Server",
|
||||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
|
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||||
"preview": "PREVIEW"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -121,7 +125,15 @@
|
|||||||
"diagramValidation": "Diagram Validation (Experimental)",
|
"diagramValidation": "Diagram Validation (Experimental)",
|
||||||
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
|
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled",
|
||||||
|
"customSystemMessage": "Custom System Message",
|
||||||
|
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||||
|
"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",
|
||||||
@@ -132,7 +144,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG Image",
|
"png": "PNG Image",
|
||||||
"svg": "SVG Image"
|
"svg": "SVG Image",
|
||||||
|
"xmlsvg": "Editable SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "Saved successfully!"
|
"savedSuccessfully": "Saved successfully!"
|
||||||
},
|
},
|
||||||
@@ -179,8 +192,11 @@
|
|||||||
"tpmMessage": "Too many requests. Please wait a moment.",
|
"tpmMessage": "Too many requests. Please wait a moment.",
|
||||||
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
|
||||||
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageApi": "Looks like you've reached today's demo limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
"messageToken": "Looks like you've reached today's token limit. We're thrilled you're enjoying it, and while ByteDance Doubao generously sponsors this demo, we've had to set a few boundaries to keep things fair for everyone.",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
"tip": "<strong>Tip:</strong> You can use your own API key (click the Settings icon) or self-host the project to bypass these limits.",
|
||||||
|
"tipSelfHosted": "<strong>Tip:</strong> You can configure your own API key in the settings to continue using the service.",
|
||||||
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
"reset": "Your limit resets tomorrow. Thanks for understanding.",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">Register here</a> to get 500K free tokens per model (including Doubao, DeepSeek and Kimi), then configure your API key in model settings.",
|
||||||
"configModel": "Use Your API Key",
|
"configModel": "Use Your API Key",
|
||||||
@@ -254,6 +270,52 @@
|
|||||||
"searchPlaceholder": "Search chats...",
|
"searchPlaceholder": "Search chats...",
|
||||||
"noResults": "No chats found"
|
"noResults": "No chats found"
|
||||||
},
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "My Templates",
|
||||||
|
"subtitle": "Your personal prompt library for quick diagram creation",
|
||||||
|
"emptyTitle": "No templates yet",
|
||||||
|
"emptyDescription": "Create your first template to start building your personal prompt library",
|
||||||
|
"createFirst": "Create First Template",
|
||||||
|
"neverUsed": "Not used yet",
|
||||||
|
"usedCount": "{count} uses",
|
||||||
|
"myTemplates": "My Templates",
|
||||||
|
"createTitle": "Create Template",
|
||||||
|
"createDescription": "Save a prompt for repeated use. Templates help you quickly start common workflows.",
|
||||||
|
"promptLabel": "Prompt",
|
||||||
|
"promptPlaceholder": "Describe the diagram you want to create...",
|
||||||
|
"promptRequired": "Prompt is required",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"titlePlaceholder": "Enter a title",
|
||||||
|
"titleHint": "Leave empty to use the first 20 characters of prompt",
|
||||||
|
"descriptionLabel": "Description",
|
||||||
|
"descriptionPlaceholder": "Add a description for this template",
|
||||||
|
"pinnedLabel": "Pin Template",
|
||||||
|
"pinnedHint": "Pinned templates appear at the top of the list",
|
||||||
|
"createButton": "Create Template",
|
||||||
|
"createFailed": "Failed to create template. Please try again.",
|
||||||
|
"editTitle": "Edit Template",
|
||||||
|
"editDescription": "Update your template content and settings.",
|
||||||
|
"updateFailed": "Failed to update template. Please try again.",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"copySuffix": "(copy)",
|
||||||
|
"deleteTitle": "Delete this template?",
|
||||||
|
"deleteDescription": "This will permanently delete this template. This action cannot be undone.",
|
||||||
|
"confirmSendTitle": "Replace current input?",
|
||||||
|
"confirmSendDescription": "You have unsent content in the input. Sending this template will replace it.",
|
||||||
|
"confirmSendButton": "Send Template",
|
||||||
|
"searchPlaceholder": "Search templates...",
|
||||||
|
"searchNoResults": "No templates match your search",
|
||||||
|
"pin": "Pin to top",
|
||||||
|
"unpin": "Unpin from top",
|
||||||
|
"saveAsTemplate": "Save as Template",
|
||||||
|
"exportTemplates": "Export Templates",
|
||||||
|
"importTemplates": "Import Templates",
|
||||||
|
"exportEmpty": "No templates to export",
|
||||||
|
"exportSuccess": "Exported {count} template(s) successfully",
|
||||||
|
"importNoFile": "Please select a JSON file",
|
||||||
|
"importFailed": "Import failed: {error}",
|
||||||
|
"importSuccess": "Imported {imported} template(s), skipped {skipped} duplicate(s)"
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"title": "Validate Diagram",
|
"title": "Validate Diagram",
|
||||||
"capturing": "Capturing",
|
"capturing": "Capturing",
|
||||||
@@ -306,6 +368,7 @@
|
|||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||||
"customEndpoint": "Custom endpoint URL",
|
"customEndpoint": "Custom endpoint URL",
|
||||||
|
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||||
"models": "Models",
|
"models": "Models",
|
||||||
"customModelId": "Custom model ID...",
|
"customModelId": "Custom model ID...",
|
||||||
"allAdded": "All added",
|
"allAdded": "All added",
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow",
|
||||||
"modelscope": "ModelScope"
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -71,8 +76,7 @@
|
|||||||
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
||||||
"cachedNote": "例はキャッシュされ、即座に応答します",
|
"cachedNote": "例はキャッシュされ、即座に応答します",
|
||||||
"mcpServer": "MCP サーバー",
|
"mcpServer": "MCP サーバー",
|
||||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
|
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
|
||||||
"preview": "プレビュー"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
@@ -121,7 +125,15 @@
|
|||||||
"diagramValidation": "ダイアグラム検証(実験的)",
|
"diagramValidation": "ダイアグラム検証(実験的)",
|
||||||
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
|
||||||
"enabled": "有効",
|
"enabled": "有効",
|
||||||
"disabled": "無効"
|
"disabled": "無効",
|
||||||
|
"customSystemMessage": "カスタムシステムメッセージ",
|
||||||
|
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||||
|
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||||
|
"panelVisibility": "ロビーパネル",
|
||||||
|
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||||
|
"showRecentChats": "最近のチャット",
|
||||||
|
"showMyTemplates": "マイテンプレート",
|
||||||
|
"showQuickExamples": "クイック例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "ダイアグラムを保存",
|
"title": "ダイアグラムを保存",
|
||||||
@@ -132,7 +144,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像"
|
"svg": "SVG 画像",
|
||||||
|
"xmlsvg": "編集可能 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存完了!"
|
"savedSuccessfully": "保存完了!"
|
||||||
},
|
},
|
||||||
@@ -179,8 +192,11 @@
|
|||||||
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
|
||||||
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
|
||||||
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageApi": "今日のデモ利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
"messageToken": "今日のトークン利用上限に達してしまったようです。楽しんでいただけて本当に嬉しいです。このデモはByteDance Doubaoのご厚意により提供されていますが、皆様に公平にご利用いただくため、少し制限を設けさせていただいております。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
"tip": "<strong>ヒント:</strong>独自の API キーを使用する(設定アイコンをクリック)か、プロジェクトをセルフホストしてこれらの制限を回避できます。",
|
||||||
|
"tipSelfHosted": "<strong>ヒント:</strong>設定で独自の API キーを設定することで、引き続きサービスをご利用いただけます。",
|
||||||
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
"reset": "制限は明日リセットされます。ご理解ありがとうございます。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">こちらから登録</a>すると、各モデル(Doubao、DeepSeek、Kimi含む)で50万トークンを無料で取得できます。モデル設定でAPIキーを設定してください。",
|
||||||
"configModel": "APIキーを使用",
|
"configModel": "APIキーを使用",
|
||||||
@@ -306,6 +322,7 @@
|
|||||||
"optional": "(オプション)",
|
"optional": "(オプション)",
|
||||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||||
"customEndpoint": "カスタムエンドポイント URL",
|
"customEndpoint": "カスタムエンドポイント URL",
|
||||||
|
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||||
"models": "モデル",
|
"models": "モデル",
|
||||||
"customModelId": "カスタムモデル ID...",
|
"customModelId": "カスタムモデル ID...",
|
||||||
"allAdded": "すべて追加済み",
|
"allAdded": "すべて追加済み",
|
||||||
@@ -338,5 +355,51 @@
|
|||||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||||
"serverDefaultModel": "サーバーデフォルトモデル"
|
"serverDefaultModel": "サーバーデフォルトモデル"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "マイテンプレート",
|
||||||
|
"subtitle": "素早いダイアグラム作成のための個人的なプロンプトライブラリ",
|
||||||
|
"emptyTitle": "テンプレートがありません",
|
||||||
|
"emptyDescription": "最初のテンプレートを作成して、個人的なプロンプトライブラリを構築しましょう",
|
||||||
|
"createFirst": "最初のテンプレートを作成",
|
||||||
|
"neverUsed": "未使用",
|
||||||
|
"usedCount": "{count} 回使用",
|
||||||
|
"myTemplates": "マイテンプレート",
|
||||||
|
"createTitle": "テンプレート作成",
|
||||||
|
"createDescription": "再利用のためにプロンプトを保存します。テンプレートを使用すると、一般的なワークフローを素早く開始できます。",
|
||||||
|
"promptLabel": "プロンプト",
|
||||||
|
"promptPlaceholder": "作成するダイアグラムを説明してください...",
|
||||||
|
"promptRequired": "プロンプトは必須です",
|
||||||
|
"titleLabel": "タイトル",
|
||||||
|
"titlePlaceholder": "タイトルを入力",
|
||||||
|
"titleHint": "空欄の場合はプロンプトの最初の20文字が使用されます",
|
||||||
|
"descriptionLabel": "説明",
|
||||||
|
"descriptionPlaceholder": "このテンプレートの説明を追加",
|
||||||
|
"pinnedLabel": "テンプレートをピン留め",
|
||||||
|
"pinnedHint": "ピン留めされたテンプレートはリストの上部に表示されます",
|
||||||
|
"createButton": "テンプレート作成",
|
||||||
|
"createFailed": "テンプレートの作成に失敗しました。もう一度お試しください。",
|
||||||
|
"editTitle": "テンプレート編集",
|
||||||
|
"editDescription": "テンプレートの内容と設定を更新します。",
|
||||||
|
"updateFailed": "テンプレートの更新に失敗しました。もう一度お試しください。",
|
||||||
|
"duplicate": "複製",
|
||||||
|
"copySuffix": "(コピー)",
|
||||||
|
"deleteTitle": "このテンプレートを削除しますか?",
|
||||||
|
"deleteDescription": "このテンプレートは完全に削除されます。この操作は取り消せません。",
|
||||||
|
"confirmSendTitle": "現在の入力を置き換えますか?",
|
||||||
|
"confirmSendDescription": "未送信のコンテンツがあります。このテンプレートを送信すると置き換えられます。",
|
||||||
|
"confirmSendButton": "テンプレートを送信",
|
||||||
|
"searchPlaceholder": "テンプレートを検索...",
|
||||||
|
"searchNoResults": "検索に一致するテンプレートがありません",
|
||||||
|
"pin": "上部にピン留め",
|
||||||
|
"unpin": "ピン留め解除",
|
||||||
|
"saveAsTemplate": "テンプレートとして保存",
|
||||||
|
"exportTemplates": "テンプレートをエクスポート",
|
||||||
|
"importTemplates": "テンプレートをインポート",
|
||||||
|
"exportEmpty": "エクスポートするテンプレートがありません",
|
||||||
|
"exportSuccess": "{count} 件のテンプレートをエクスポートしました",
|
||||||
|
"importNoFile": "JSON ファイルを選択してください",
|
||||||
|
"importFailed": "インポートに失敗しました:{error}",
|
||||||
|
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow",
|
||||||
"modelscope": "ModelScope"
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的圖表或上傳檔案...",
|
"placeholder": "描述您的圖表或上傳檔案...",
|
||||||
@@ -71,8 +76,7 @@
|
|||||||
"creativeDescription": "繪製有趣且富有創意的內容",
|
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||||
"cachedNote": "範例已快取,可即時回應",
|
"cachedNote": "範例已快取,可即時回應",
|
||||||
"mcpServer": "MCP 伺服器",
|
"mcpServer": "MCP 伺服器",
|
||||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||||
"preview": "預覽"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
@@ -121,7 +125,15 @@
|
|||||||
"diagramValidation": "圖表驗證(實驗性)",
|
"diagramValidation": "圖表驗證(實驗性)",
|
||||||
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||||
"enabled": "已啟用",
|
"enabled": "已啟用",
|
||||||
"disabled": "已停用"
|
"disabled": "已停用",
|
||||||
|
"customSystemMessage": "自訂系統訊息",
|
||||||
|
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||||
|
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||||
|
"panelVisibility": "大廳面板",
|
||||||
|
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的範本",
|
||||||
|
"showQuickExamples": "快速範例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "儲存圖表",
|
"title": "儲存圖表",
|
||||||
@@ -132,7 +144,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 圖片",
|
"png": "PNG 圖片",
|
||||||
"svg": "SVG 圖片"
|
"svg": "SVG 圖片",
|
||||||
|
"xmlsvg": "可編輯 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "儲存成功!"
|
"savedSuccessfully": "儲存成功!"
|
||||||
},
|
},
|
||||||
@@ -179,8 +192,11 @@
|
|||||||
"tpmMessage": "請求過多。請稍等片刻。",
|
"tpmMessage": "請求過多。請稍等片刻。",
|
||||||
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
|
||||||
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||||
|
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
|
||||||
"reset": "您的限制將在明天重設。感謝您的理解。",
|
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
||||||
"configModel": "使用您的金鑰",
|
"configModel": "使用您的金鑰",
|
||||||
@@ -254,6 +270,52 @@
|
|||||||
"searchPlaceholder": "搜尋對話...",
|
"searchPlaceholder": "搜尋對話...",
|
||||||
"noResults": "未找到對話"
|
"noResults": "未找到對話"
|
||||||
},
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "我的範本",
|
||||||
|
"subtitle": "快速建立圖表的個人提示庫",
|
||||||
|
"emptyTitle": "尚無範本",
|
||||||
|
"emptyDescription": "建立您的第一個範本,開始構建您的個人提示庫",
|
||||||
|
"createFirst": "建立第一個範本",
|
||||||
|
"neverUsed": "未使用過",
|
||||||
|
"usedCount": "使用 {count} 次",
|
||||||
|
"myTemplates": "我的範本",
|
||||||
|
"createTitle": "建立範本",
|
||||||
|
"createDescription": "儲存常用提示以便重複使用。範本幫助您快速開始常用工作流程。",
|
||||||
|
"promptLabel": "提示",
|
||||||
|
"promptPlaceholder": "描述您想建立的圖表...",
|
||||||
|
"promptRequired": "提示為必填項",
|
||||||
|
"titleLabel": "標題",
|
||||||
|
"titlePlaceholder": "輸入標題",
|
||||||
|
"titleHint": "留空則使用提示的前 20 個字元",
|
||||||
|
"descriptionLabel": "描述",
|
||||||
|
"descriptionPlaceholder": "為此範本新增描述",
|
||||||
|
"pinnedLabel": "釘選範本",
|
||||||
|
"pinnedHint": "釘選的範本會顯示在清單頂部",
|
||||||
|
"createButton": "建立範本",
|
||||||
|
"createFailed": "建立範本失敗,請重試。",
|
||||||
|
"editTitle": "編輯範本",
|
||||||
|
"editDescription": "更新範本內容和設定。",
|
||||||
|
"updateFailed": "更新範本失敗,請重試。",
|
||||||
|
"duplicate": "複製",
|
||||||
|
"copySuffix": "(副本)",
|
||||||
|
"deleteTitle": "刪除此範本?",
|
||||||
|
"deleteDescription": "此操作將永久刪除該範本,無法復原。",
|
||||||
|
"confirmSendTitle": "取代目前輸入?",
|
||||||
|
"confirmSendDescription": "您有未傳送的內容。傳送此範本將取代它。",
|
||||||
|
"confirmSendButton": "傳送範本",
|
||||||
|
"searchPlaceholder": "搜尋範本...",
|
||||||
|
"searchNoResults": "沒有符合的範本",
|
||||||
|
"pin": "置頂",
|
||||||
|
"unpin": "取消置頂",
|
||||||
|
"saveAsTemplate": "儲存為範本",
|
||||||
|
"exportTemplates": "匯出範本",
|
||||||
|
"importTemplates": "匯入範本",
|
||||||
|
"exportEmpty": "沒有可匯出的範本",
|
||||||
|
"exportSuccess": "成功匯出 {count} 個範本",
|
||||||
|
"importNoFile": "請選擇一個 JSON 檔案",
|
||||||
|
"importFailed": "匯入失敗:{error}",
|
||||||
|
"importSuccess": "成功匯入 {imported} 個範本,跳過 {skipped} 個重複"
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"title": "驗證圖表",
|
"title": "驗證圖表",
|
||||||
"capturing": "截圖中",
|
"capturing": "截圖中",
|
||||||
@@ -306,6 +368,7 @@
|
|||||||
"optional": "(可選)",
|
"optional": "(可選)",
|
||||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||||
"customEndpoint": "自訂端點 URL",
|
"customEndpoint": "自訂端點 URL",
|
||||||
|
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自訂模型 ID...",
|
"customModelId": "自訂模型 ID...",
|
||||||
"allAdded": "已全部新增",
|
"allAdded": "已全部新增",
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow",
|
"siliconflow": "SiliconFlow",
|
||||||
"modelscope": "ModelScope"
|
"modelscope": "ModelScope",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"glm": "GLM",
|
||||||
|
"qwen": "Qwen",
|
||||||
|
"kimi": "Kimi",
|
||||||
|
"qiniu": "Qiniu"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -71,8 +76,7 @@
|
|||||||
"creativeDescription": "绘制有趣且富有创意的内容",
|
"creativeDescription": "绘制有趣且富有创意的内容",
|
||||||
"cachedNote": "示例已缓存,可即时响应",
|
"cachedNote": "示例已缓存,可即时响应",
|
||||||
"mcpServer": "MCP 服务器",
|
"mcpServer": "MCP 服务器",
|
||||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||||
"preview": "预览"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
@@ -121,7 +125,15 @@
|
|||||||
"diagramValidation": "图表验证(实验性)",
|
"diagramValidation": "图表验证(实验性)",
|
||||||
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用"
|
"disabled": "已禁用",
|
||||||
|
"customSystemMessage": "自定义系统消息",
|
||||||
|
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||||
|
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||||
|
"panelVisibility": "大厅面板",
|
||||||
|
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||||
|
"showRecentChats": "最近聊天",
|
||||||
|
"showMyTemplates": "我的模板",
|
||||||
|
"showQuickExamples": "快速示例"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"title": "保存图表",
|
"title": "保存图表",
|
||||||
@@ -132,7 +144,8 @@
|
|||||||
"formats": {
|
"formats": {
|
||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片"
|
"svg": "SVG 图片",
|
||||||
|
"xmlsvg": "可编辑 SVG"
|
||||||
},
|
},
|
||||||
"savedSuccessfully": "保存成功!"
|
"savedSuccessfully": "保存成功!"
|
||||||
},
|
},
|
||||||
@@ -179,8 +192,11 @@
|
|||||||
"tpmMessage": "请求过多。请稍等片刻。",
|
"tpmMessage": "请求过多。请稍等片刻。",
|
||||||
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
"tpmMessageDetailed": "达到速率限制({limit} 令牌/分钟)。请等待 {seconds} 秒后再发送请求。",
|
||||||
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
"messageApi": "看来您今天的体验次数已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
|
"messageApiSelfHosted": null,
|
||||||
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||||
|
"messageTokenSelfHosted": null,
|
||||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||||
|
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
|
||||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
||||||
"configModel": "使用您的密钥",
|
"configModel": "使用您的密钥",
|
||||||
@@ -254,6 +270,52 @@
|
|||||||
"searchPlaceholder": "搜索对话...",
|
"searchPlaceholder": "搜索对话...",
|
||||||
"noResults": "未找到对话"
|
"noResults": "未找到对话"
|
||||||
},
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "我的模板库",
|
||||||
|
"subtitle": "您的个人 prompt 库,快速创建图表",
|
||||||
|
"emptyTitle": "暂无模板",
|
||||||
|
"emptyDescription": "创建您的第一个模板,开始构建个人 prompt 库",
|
||||||
|
"createFirst": "创建第一个模板",
|
||||||
|
"neverUsed": "未使用过",
|
||||||
|
"usedCount": "使用 {count} 次",
|
||||||
|
"myTemplates": "我的模板",
|
||||||
|
"createTitle": "创建模板",
|
||||||
|
"createDescription": "保存常用 prompt 以便重复使用。模板帮助您快速开始常用工作流。",
|
||||||
|
"promptLabel": "提示词",
|
||||||
|
"promptPlaceholder": "描述您想要创建的图表...",
|
||||||
|
"promptRequired": "提示词不能为空",
|
||||||
|
"titleLabel": "标题",
|
||||||
|
"titlePlaceholder": "输入标题",
|
||||||
|
"titleHint": "留空则使用提示词前 20 个字符作为标题",
|
||||||
|
"descriptionLabel": "描述",
|
||||||
|
"descriptionPlaceholder": "为此模板添加描述",
|
||||||
|
"pinnedLabel": "置顶模板",
|
||||||
|
"pinnedHint": "置顶的模板会显示在列表顶部",
|
||||||
|
"createButton": "创建模板",
|
||||||
|
"createFailed": "创建模板失败,请重试。",
|
||||||
|
"editTitle": "编辑模板",
|
||||||
|
"editDescription": "更新模板内容和设置。",
|
||||||
|
"updateFailed": "更新模板失败,请重试。",
|
||||||
|
"duplicate": "复制",
|
||||||
|
"copySuffix": "(副本)",
|
||||||
|
"deleteTitle": "删除此模板?",
|
||||||
|
"deleteDescription": "此操作将永久删除该模板,无法撤销。",
|
||||||
|
"confirmSendTitle": "替换当前输入?",
|
||||||
|
"confirmSendDescription": "您有未发送的内容。发送此模板将替换它。",
|
||||||
|
"confirmSendButton": "发送模板",
|
||||||
|
"searchPlaceholder": "搜索模板...",
|
||||||
|
"searchNoResults": "没有匹配的模板",
|
||||||
|
"pin": "置顶",
|
||||||
|
"unpin": "取消置顶",
|
||||||
|
"saveAsTemplate": "保存为模板",
|
||||||
|
"exportTemplates": "导出模板",
|
||||||
|
"importTemplates": "导入模板",
|
||||||
|
"exportEmpty": "没有可导出的模板",
|
||||||
|
"exportSuccess": "成功导出 {count} 个模板",
|
||||||
|
"importNoFile": "请选择一个 JSON 文件",
|
||||||
|
"importFailed": "导入失败:{error}",
|
||||||
|
"importSuccess": "成功导入 {imported} 个模板,跳过 {skipped} 个重复"
|
||||||
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"title": "验证图表",
|
"title": "验证图表",
|
||||||
"capturing": "截图中",
|
"capturing": "截图中",
|
||||||
@@ -306,6 +368,7 @@
|
|||||||
"optional": "(可选)",
|
"optional": "(可选)",
|
||||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||||
"customEndpoint": "自定义端点 URL",
|
"customEndpoint": "自定义端点 URL",
|
||||||
|
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||||
"models": "模型",
|
"models": "模型",
|
||||||
"customModelId": "自定义模型 ID...",
|
"customModelId": "自定义模型 ID...",
|
||||||
"allAdded": "已全部添加",
|
"allAdded": "已全部添加",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||||
import { nanoid } from "nanoid"
|
import { nanoid } from "nanoid"
|
||||||
|
import type { Template } from "./template-storage"
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const DB_NAME = "next-ai-drawio"
|
const DB_NAME = "next-ai-drawio"
|
||||||
const DB_VERSION = 1
|
const DB_VERSION = 2
|
||||||
const STORE_NAME = "sessions"
|
const STORE_NAME = "sessions"
|
||||||
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||||
const MAX_SESSIONS = 50
|
const MAX_SESSIONS = 50
|
||||||
@@ -43,10 +44,47 @@ 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) {
|
||||||
@@ -58,8 +96,41 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
|||||||
})
|
})
|
||||||
store.createIndex("by-updated", "updatedAt")
|
store.createIndex("by-updated", "updatedAt")
|
||||||
}
|
}
|
||||||
// Future migrations: if (oldVersion < 2) { ... }
|
// Version 2: templates store (added by template-storage.ts)
|
||||||
|
// Note: We also need to include this here to ensure the upgrade
|
||||||
|
// callback properly handles all migrations when opening from this file
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
// Check if templates store already exists (created by template-storage.ts)
|
||||||
|
if (!db.objectStoreNames.contains("templates")) {
|
||||||
|
const templateStore = db.createObjectStore(
|
||||||
|
"templates",
|
||||||
|
{
|
||||||
|
keyPath: "id",
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
templateStore.createIndex("by-updated", "updatedAt")
|
||||||
|
templateStore.createIndex("by-pinned", "pinned")
|
||||||
|
templateStore.createIndex("by-run-count", "runCount")
|
||||||
|
templateStore.createIndex("by-last-used", "lastUsedAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
terminated() {
|
||||||
|
resetDBPromise()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dbPromise
|
||||||
|
.then((db) => {
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close()
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
db.onclose = () => {
|
||||||
|
resetDBPromise()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
resetDBPromise()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return dbPromise
|
return dbPromise
|
||||||
@@ -75,11 +146,24 @@ 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 {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
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[] = []
|
||||||
@@ -94,12 +178,14 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
|||||||
createdAt: s.createdAt,
|
createdAt: s.createdAt,
|
||||||
updatedAt: s.updatedAt,
|
updatedAt: s.updatedAt,
|
||||||
messageCount: s.messages.length,
|
messageCount: s.messages.length,
|
||||||
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
hasDiagram:
|
||||||
|
!!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 []
|
||||||
@@ -109,8 +195,9 @@ 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 {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
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
|
||||||
@@ -120,8 +207,9 @@ 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 {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
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
|
||||||
@@ -133,8 +221,9 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
|||||||
await deleteOldestSession()
|
await deleteOldestSession()
|
||||||
// Retry once
|
// Retry once
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
await db.put(STORE_NAME, session)
|
await db.put(STORE_NAME, session)
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -153,8 +242,9 @@ 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 {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -163,8 +253,9 @@ 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 {
|
||||||
const db = await getDB()
|
return await withDB(async (db) => {
|
||||||
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
|
||||||
@@ -174,7 +265,7 @@ export async function getSessionCount(): Promise<number> {
|
|||||||
export async function deleteOldestSession(): Promise<void> {
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
if (!isIndexedDBAvailable()) return
|
if (!isIndexedDBAvailable()) return
|
||||||
try {
|
try {
|
||||||
const db = await getDB()
|
await withDB(async (db) => {
|
||||||
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()
|
||||||
@@ -182,6 +273,7 @@ export async function deleteOldestSession(): Promise<void> {
|
|||||||
await cursor.delete()
|
await cursor.delete()
|
||||||
}
|
}
|
||||||
await tx.done
|
await tx.done
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete oldest session:", error)
|
console.error("Failed to delete oldest session:", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,12 @@ export const STORAGE_KEYS = {
|
|||||||
|
|
||||||
// Diagram validation
|
// Diagram validation
|
||||||
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
|
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
437
lib/template-storage.ts
Normal file
437
lib/template-storage.ts
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ export type ProviderName =
|
|||||||
| "edgeone"
|
| "edgeone"
|
||||||
| "doubao"
|
| "doubao"
|
||||||
| "modelscope"
|
| "modelscope"
|
||||||
|
| "glm"
|
||||||
|
| "qwen"
|
||||||
|
| "qiniu"
|
||||||
|
| "kimi"
|
||||||
|
| "minimax"
|
||||||
|
| "novita"
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -79,6 +85,26 @@ export interface FlattenedModel {
|
|||||||
baseUrlEnv?: string
|
baseUrlEnv?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map provider names to models.dev logo names
|
||||||
|
export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||||
|
openai: "openai",
|
||||||
|
anthropic: "anthropic",
|
||||||
|
google: "google",
|
||||||
|
azure: "azure",
|
||||||
|
bedrock: "amazon-bedrock",
|
||||||
|
openrouter: "openrouter",
|
||||||
|
deepseek: "deepseek",
|
||||||
|
siliconflow: "siliconflow",
|
||||||
|
sglang: "openai", // SGLang is OpenAI-compatible
|
||||||
|
gateway: "vercel",
|
||||||
|
edgeone: "tencent-cloud",
|
||||||
|
vertexai: "google",
|
||||||
|
doubao: "bytedance",
|
||||||
|
modelscope: "modelscope",
|
||||||
|
minimax: "minimax",
|
||||||
|
novita: "novita",
|
||||||
|
}
|
||||||
|
|
||||||
// Provider metadata
|
// Provider metadata
|
||||||
export const PROVIDER_INFO: Record<
|
export const PROVIDER_INFO: Record<
|
||||||
ProviderName,
|
ProviderName,
|
||||||
@@ -135,6 +161,30 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "ModelScope",
|
label: "ModelScope",
|
||||||
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||||
},
|
},
|
||||||
|
glm: {
|
||||||
|
label: "GLM (Zhipu)",
|
||||||
|
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
label: "Qwen (Alibaba)",
|
||||||
|
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
},
|
||||||
|
qiniu: {
|
||||||
|
label: "Qiniu",
|
||||||
|
defaultBaseUrl: "https://api.qnaigc.com/v1",
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
label: "Kimi (Moonshot)",
|
||||||
|
defaultBaseUrl: "https://api.moonshot.cn/v1",
|
||||||
|
},
|
||||||
|
minimax: {
|
||||||
|
label: "MiniMax",
|
||||||
|
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
|
||||||
@@ -299,6 +349,19 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
|||||||
"deepseek-ai/DeepSeek-R1-0528",
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
"deepseek-ai/DeepSeek-V3.2",
|
"deepseek-ai/DeepSeek-V3.2",
|
||||||
],
|
],
|
||||||
|
minimax: [
|
||||||
|
// MiniMax models (Anthropic-compatible API)
|
||||||
|
"MiniMax-M2.7",
|
||||||
|
"MiniMax-M2.7-highspeed",
|
||||||
|
"MiniMax-M2.5",
|
||||||
|
"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
|
||||||
|
|||||||
11204
package-lock.json
generated
11204
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.12",
|
"version": "0.4.14",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
"@langfuse/tracing": "^4.4.9",
|
"@langfuse/tracing": "^4.4.9",
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@opennextjs/cloudflare": "1.16.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.211.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.214.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.563.0",
|
"lucide-react": "^0.577.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@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.2",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
|||||||
19
packages/mcp-server/package-lock.json
generated
19
packages/mcp-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
@@ -481,9 +481,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.26.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.9",
|
"@hono/node-server": "^1.19.9",
|
||||||
@@ -521,9 +521,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.12",
|
"version": "24.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||||
"integrity": "sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1035,7 +1035,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -1268,7 +1267,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -2068,7 +2066,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.16",
|
"version": "0.2.0",
|
||||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -4,6 +4,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
||||||
|
|
||||||
|
function readBody(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
cb: (body: string) => void,
|
||||||
|
): void {
|
||||||
|
let body = ""
|
||||||
|
let size = 0
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > MAX_BODY_BYTES) {
|
||||||
|
res.writeHead(413, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify({ error: "Payload too large" }))
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body += chunk
|
||||||
|
})
|
||||||
|
req.on("end", () => cb(body))
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addHistory,
|
addHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
@@ -155,7 +178,7 @@ export function startHttpServer(port = 6002): Promise<number> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.listen(port, () => {
|
server.listen(port, "127.0.0.1", () => {
|
||||||
serverPort = port
|
serverPort = port
|
||||||
log.info(`HTTP server running on http://localhost:${port}`)
|
log.info(`HTTP server running on http://localhost:${port}`)
|
||||||
resolve(port)
|
resolve(port)
|
||||||
@@ -198,9 +221,12 @@ function handleRequest(
|
|||||||
): void {
|
): void {
|
||||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||||
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
const requestOrigin = req.headers.origin
|
||||||
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204)
|
res.writeHead(204)
|
||||||
@@ -263,11 +289,7 @@ function handleStateApi(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
let body = ""
|
readBody(req, res, (body) => {
|
||||||
req.on("data", (chunk) => {
|
|
||||||
body += chunk
|
|
||||||
})
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(body)
|
const data = JSON.parse(body)
|
||||||
const { sessionId } = data
|
const { sessionId } = data
|
||||||
@@ -344,11 +366,7 @@ function handleRestoreApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = ""
|
readBody(req, res, (body) => {
|
||||||
req.on("data", (chunk) => {
|
|
||||||
body += chunk
|
|
||||||
})
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
try {
|
||||||
const { sessionId, index } = JSON.parse(body)
|
const { sessionId, index } = JSON.parse(body)
|
||||||
if (!sessionId || index === undefined) {
|
if (!sessionId || index === undefined) {
|
||||||
@@ -390,11 +408,7 @@ function handleHistorySvgApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = ""
|
readBody(req, res, (body) => {
|
||||||
req.on("data", (chunk) => {
|
|
||||||
body += chunk
|
|
||||||
})
|
|
||||||
req.on("end", () => {
|
|
||||||
try {
|
try {
|
||||||
const { sessionId, svg } = JSON.parse(body)
|
const { sessionId, svg } = JSON.parse(body)
|
||||||
if (!sessionId || !svg) {
|
if (!sessionId || !svg) {
|
||||||
|
|||||||
@@ -155,19 +155,22 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"create_new_diagram",
|
"create_new_diagram",
|
||||||
{
|
{
|
||||||
description: `Create a NEW diagram from mxGraphModel XML. Use this when creating a diagram from scratch or replacing the current diagram entirely.
|
description: `Create a NEW diagram from mxGraphModel XML. ONLY use this when creating a diagram from scratch.
|
||||||
|
|
||||||
|
⚠️ DO NOT use this tool to modify an existing diagram — it will DESTROY all existing content and user changes. Use edit_diagram instead for ANY modifications to an existing diagram.
|
||||||
|
|
||||||
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
|
CRITICAL: You MUST provide the 'xml' argument in EVERY call. Do NOT call this tool without xml.
|
||||||
|
|
||||||
When to use this tool:
|
When to use this tool:
|
||||||
- Creating a new diagram from scratch
|
- Creating a new diagram from scratch (no existing diagram)
|
||||||
- Replacing the current diagram with a completely different one
|
- The user explicitly asks to "start over" or "create a new diagram"
|
||||||
- Major structural changes that require regenerating the diagram
|
|
||||||
|
|
||||||
When to use edit_diagram instead:
|
When to use edit_diagram instead (ALWAYS prefer edit_diagram if a diagram already exists):
|
||||||
- Small modifications to existing diagram
|
- ANY modifications to an existing diagram
|
||||||
- Adding/removing individual elements
|
- Adding/removing/moving elements
|
||||||
- Changing labels, colors, or positions
|
- Changing labels, colors, styles, or positions
|
||||||
|
- Restructuring or reorganizing existing content
|
||||||
|
- Adding new elements to an existing diagram
|
||||||
|
|
||||||
XML FORMAT - Full mxGraphModel structure:
|
XML FORMAT - Full mxGraphModel structure:
|
||||||
<mxGraphModel>
|
<mxGraphModel>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
1
public/agile_scrum.svg
Normal file
1
public/agile_scrum.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 30 KiB |
1
public/auth.svg
Normal file
1
public/auth.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 42 KiB |
1
public/inno.svg
Normal file
1
public/inno.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
1
public/rag_prod.svg
Normal file
1
public/rag_prod.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
1
public/resnet50.svg
Normal file
1
public/resnet50.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 39 KiB |
BIN
public/volcengine-invite.png
Normal file
BIN
public/volcengine-invite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
@@ -1,7 +1,6 @@
|
|||||||
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
expectBeforeAndAfterReload,
|
|
||||||
getChatInput,
|
getChatInput,
|
||||||
getIframe,
|
getIframe,
|
||||||
getIframeContent,
|
getIframeContent,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { TEST_NODE_XML } from "./fixtures/diagrams"
|
import { TEST_NODE_XML } from "./fixtures/diagrams"
|
||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
getChatInput,
|
|
||||||
getIframe,
|
getIframe,
|
||||||
getIframeContent,
|
getIframeContent,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
expect,
|
expect,
|
||||||
expectBeforeAndAfterReload,
|
|
||||||
getChatInput,
|
getChatInput,
|
||||||
getIframe,
|
getIframe,
|
||||||
openSettings,
|
openSettings,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
createMixedMock,
|
createMixedMock,
|
||||||
createMultiTurnMock,
|
createMultiTurnMock,
|
||||||
expect,
|
expect,
|
||||||
getChatInput,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
test,
|
test,
|
||||||
waitForComplete,
|
waitForComplete,
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
|
||||||
expect,
|
|
||||||
getIframe,
|
|
||||||
getSettingsButton,
|
|
||||||
openSettings,
|
|
||||||
test,
|
|
||||||
} from "./lib/fixtures"
|
|
||||||
|
|
||||||
test.describe("Settings", () => {
|
test.describe("Settings", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|||||||
@@ -174,6 +174,19 @@ describe("supportsImageInput", () => {
|
|||||||
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
|
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("returns false for Moonshot v1 text models", () => {
|
||||||
|
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for MiniMax text models", () => {
|
||||||
|
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2.5")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2")).toBe(false)
|
||||||
|
expect(supportsImageInput("MiniMax-M2.5-highspeed")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it("returns false for DeepSeek text models", () => {
|
it("returns false for DeepSeek text models", () => {
|
||||||
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
||||||
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
||||||
@@ -182,6 +195,32 @@ describe("supportsImageInput", () => {
|
|||||||
it("returns false for Qwen text models", () => {
|
it("returns false for Qwen text models", () => {
|
||||||
expect(supportsImageInput("qwen-turbo")).toBe(false)
|
expect(supportsImageInput("qwen-turbo")).toBe(false)
|
||||||
expect(supportsImageInput("qwen-plus")).toBe(false)
|
expect(supportsImageInput("qwen-plus")).toBe(false)
|
||||||
|
expect(supportsImageInput("qwen3-max")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for Qwen vision models", () => {
|
||||||
|
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-flash")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for GLM text models", () => {
|
||||||
|
expect(supportsImageInput("glm-4")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-plus")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-flash")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4-long")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-4.7")).toBe(false)
|
||||||
|
expect(supportsImageInput("glm-5")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for GLM vision models", () => {
|
||||||
|
expect(supportsImageInput("glm-4v")).toBe(true)
|
||||||
|
expect(supportsImageInput("glm-4v-9b")).toBe(true)
|
||||||
|
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns true for Claude and GPT models by default", () => {
|
it("returns true for Claude and GPT models by default", () => {
|
||||||
|
|||||||
269
tests/unit/template-storage.test.ts
Normal file
269
tests/unit/template-storage.test.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
generateDefaultTitle,
|
||||||
|
searchTemplates,
|
||||||
|
sortTemplates,
|
||||||
|
type Template,
|
||||||
|
validateImportData,
|
||||||
|
} from "@/lib/template-storage"
|
||||||
|
|
||||||
|
describe("generateDefaultTitle", () => {
|
||||||
|
it("returns the prompt as-is when 20 chars or fewer", () => {
|
||||||
|
expect(generateDefaultTitle("Hello")).toBe("Hello")
|
||||||
|
expect(generateDefaultTitle("A".repeat(20))).toBe("A".repeat(20))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("truncates to 20 chars with ellipsis for longer prompts", () => {
|
||||||
|
const result = generateDefaultTitle(
|
||||||
|
"This is a very long prompt that should be truncated",
|
||||||
|
)
|
||||||
|
expect(result).toBe("This is a very long...")
|
||||||
|
expect(result.length).toBeGreaterThan(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims whitespace before truncating", () => {
|
||||||
|
expect(generateDefaultTitle(" hello ")).toBe("hello")
|
||||||
|
expect(generateDefaultTitle(" " + "x".repeat(25) + " ")).toBe(
|
||||||
|
"xxxxxxxxxxxxxxxxxxxx...",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sortTemplates", () => {
|
||||||
|
const baseTemplate = (
|
||||||
|
overrides: Partial<Omit<Template, "id">> & Pick<Template, "id">,
|
||||||
|
): Template => ({
|
||||||
|
title: "Test",
|
||||||
|
prompt: "test prompt",
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts pinned templates first", () => {
|
||||||
|
const templates = [
|
||||||
|
baseTemplate({ id: "1", runCount: 10 }),
|
||||||
|
baseTemplate({ id: "2", pinned: true, runCount: 1 }),
|
||||||
|
]
|
||||||
|
const result = sortTemplates(templates)
|
||||||
|
expect(result[0].id).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by runCount descending among non-pinned", () => {
|
||||||
|
const templates = [
|
||||||
|
baseTemplate({ id: "1", runCount: 5 }),
|
||||||
|
baseTemplate({ id: "2", runCount: 10 }),
|
||||||
|
baseTemplate({ id: "3", runCount: 1 }),
|
||||||
|
]
|
||||||
|
const result = sortTemplates(templates)
|
||||||
|
expect(result.map((t) => t.id)).toEqual(["2", "1", "3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by lastUsedAt descending when runCount is equal", () => {
|
||||||
|
const templates = [
|
||||||
|
baseTemplate({ id: "1", runCount: 5, lastUsedAt: 100 }),
|
||||||
|
baseTemplate({ id: "2", runCount: 5, lastUsedAt: 200 }),
|
||||||
|
]
|
||||||
|
const result = sortTemplates(templates)
|
||||||
|
expect(result[0].id).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by updatedAt descending when runCount and lastUsedAt are equal", () => {
|
||||||
|
const templates = [
|
||||||
|
baseTemplate({
|
||||||
|
id: "1",
|
||||||
|
runCount: 5,
|
||||||
|
lastUsedAt: 100,
|
||||||
|
updatedAt: 50,
|
||||||
|
}),
|
||||||
|
baseTemplate({
|
||||||
|
id: "2",
|
||||||
|
runCount: 5,
|
||||||
|
lastUsedAt: 100,
|
||||||
|
updatedAt: 150,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const result = sortTemplates(templates)
|
||||||
|
expect(result[0].id).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not mutate the original array", () => {
|
||||||
|
const templates = [
|
||||||
|
baseTemplate({ id: "1", runCount: 1 }),
|
||||||
|
baseTemplate({ id: "2", runCount: 10 }),
|
||||||
|
]
|
||||||
|
const copy = [...templates]
|
||||||
|
sortTemplates(templates)
|
||||||
|
expect(templates.map((t) => t.id)).toEqual(copy.map((t) => t.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array for empty input", () => {
|
||||||
|
expect(sortTemplates([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("searchTemplates", () => {
|
||||||
|
const templates: Template[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Flowchart",
|
||||||
|
prompt: "Create a flowchart",
|
||||||
|
description: "Basic flowchart template",
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "ER Diagram",
|
||||||
|
prompt: "Design a database",
|
||||||
|
description: "Entity relationship diagram",
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Network",
|
||||||
|
prompt: "Draw network topology",
|
||||||
|
createdAt: 1000,
|
||||||
|
updatedAt: 1000,
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it("returns all templates when query is empty", () => {
|
||||||
|
expect(searchTemplates(templates, "")).toHaveLength(3)
|
||||||
|
expect(searchTemplates(templates, " ")).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("searches by title", () => {
|
||||||
|
const result = searchTemplates(templates, "flowchart")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("searches by description", () => {
|
||||||
|
const result = searchTemplates(templates, "entity")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const result = searchTemplates(templates, "FLOWCHART")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when no match", () => {
|
||||||
|
const result = searchTemplates(templates, "nonexistent")
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles templates without description", () => {
|
||||||
|
const result = searchTemplates(templates, "network")
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("3")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validateImportData", () => {
|
||||||
|
it("rejects non-object input", () => {
|
||||||
|
expect(validateImportData(null).valid).toBe(false)
|
||||||
|
expect(validateImportData("string").valid).toBe(false)
|
||||||
|
expect(validateImportData(123).valid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects missing schemaVersion", () => {
|
||||||
|
const result = validateImportData({ templates: [] })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("schemaVersion")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects missing templates array", () => {
|
||||||
|
const result = validateImportData({ schemaVersion: 1 })
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("templates")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects template with missing prompt", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [{ title: "Test" }],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("prompt")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects template with empty prompt", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [{ title: "Test", prompt: " " }],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("prompt")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects template with missing title", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [{ prompt: "test" }],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("title")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts valid data", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Test",
|
||||||
|
prompt: "test prompt",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
clickCount: 0,
|
||||||
|
runCount: 0,
|
||||||
|
lastUsedAt: 0,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts empty templates array", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reports index of invalid template", () => {
|
||||||
|
const result = validateImportData({
|
||||||
|
schemaVersion: 1,
|
||||||
|
templates: [
|
||||||
|
{ title: "Valid", prompt: "valid" },
|
||||||
|
{ title: "Also Valid", prompt: "also valid" },
|
||||||
|
{ title: "No Prompt" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("index 2")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"packages",
|
"packages",
|
||||||
"electron",
|
"electron",
|
||||||
"electron-standalone",
|
"electron-standalone",
|
||||||
"dist-electron"
|
"dist-electron",
|
||||||
|
"release"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user