mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-07 16:52:27 +08:00
Compare commits
32 Commits
493ee168b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad4a9b303 | ||
|
|
dcf222114c | ||
|
|
4ece615548 | ||
|
|
54fd48506d | ||
|
|
ffcb241383 | ||
|
|
79491e2143 | ||
|
|
6326f9dec6 | ||
|
|
625d8f2afe | ||
|
|
0026639ee8 | ||
|
|
c7a85d398f | ||
|
|
3ce047f794 | ||
|
|
2c2d35940b | ||
|
|
02366cabfb | ||
|
|
3e0c3bcb36 | ||
|
|
ce2237f92e | ||
|
|
2637da3215 | ||
|
|
24325c178f | ||
|
|
814f448cb0 | ||
|
|
4dc774d03f | ||
|
|
bc22b7c315 | ||
|
|
8c1cc19d94 | ||
|
|
03c3ae6d5b | ||
|
|
ddde0654a6 | ||
|
|
bc5709267c | ||
|
|
6fbc7b340f | ||
|
|
3c8f420c3c | ||
|
|
f240c494ac | ||
|
|
a22d7025a3 | ||
|
|
2159db5586 | ||
|
|
ada06260db | ||
|
|
02527526ba | ||
|
|
77a2f6f6fa |
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -20,15 +20,42 @@ npm run lint # Check lint errors
|
|||||||
npm run check # Run all checks (CI)
|
npm run check # Run all checks (CI)
|
||||||
```
|
```
|
||||||
|
|
||||||
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
Git hooks via Husky run automatically:
|
||||||
|
- **Pre-commit**: Biome (format/lint) + TypeScript type check
|
||||||
|
- **Pre-push**: Unit tests
|
||||||
|
|
||||||
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests before submitting PRs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # Unit tests (Vitest)
|
||||||
|
npm run test:e2e # E2E tests (Playwright)
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
|
||||||
|
|
||||||
|
To run a specific test file:
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/e2e/diagram-generation.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
To run tests with UI mode:
|
||||||
|
```bash
|
||||||
|
npx playwright test --ui
|
||||||
|
```
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
2. Make changes and ensure `npm run check` passes
|
2. Make changes (pre-commit runs lint + type check automatically)
|
||||||
3. Submit PR against `main` with a clear description
|
3. Run E2E tests with `npm run test:e2e`
|
||||||
|
4. Push (pre-push runs unit tests automatically)
|
||||||
|
5. Submit PR against `main` with a clear description
|
||||||
|
|
||||||
|
CI will run the full test suite on your PR.
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/electron-release.yml
vendored
2
.github/workflows/electron-release.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Build and publish Electron app
|
- name: Build and publish Electron app
|
||||||
run: npm run dist:${{ matrix.platform }}
|
run: npm run dist:${{ matrix.platform }}
|
||||||
|
|||||||
75
.github/workflows/test.yml
vendored
Normal file
75
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-unit:
|
||||||
|
name: Lint & Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test -- --run
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Install Playwright deps (cached)
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,6 +14,8 @@ packages/*/dist
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
npx tsc --noEmit
|
||||||
|
|||||||
4
.husky/pre-push
Normal file
4
.husky/pre-push
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Skip if node_modules not installed (e.g., on EC2 push server)
|
||||||
|
if [ -d "node_modules" ]; then
|
||||||
|
npm run test -- --run
|
||||||
|
fi
|
||||||
@@ -30,6 +30,10 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
|
|||||||
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
|
||||||
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
|
||||||
|
|
||||||
|
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=""
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
|
||||||
|
|
||||||
# Build Next.js application (standalone mode)
|
# Build Next.js application (standalone mode)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
|
||||||
- [Deploy on Vercel (Recommended)](#deploy-on-vercel-recommended)
|
- [Deploy on Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [Multi-Provider Support](#multi-provider-support)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
@@ -185,7 +185,7 @@ Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/doc
|
|||||||
|
|
||||||
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai).
|
||||||
|
|
||||||
### Deploy on Vercel (Recommended)
|
### Deploy on Vercel
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -211,6 +211,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ export const metadata: Metadata = {
|
|||||||
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000) {
|
|
||||||
return `${num / 1000}k`
|
|
||||||
}
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutCN() {
|
export default function AboutCN() {
|
||||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
|
||||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
|
||||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -108,42 +97,6 @@ export default function AboutCN() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
当前使用限制:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyRequestLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
请求/天
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Token/天
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Token/分钟
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="flex items-center gap-3 my-5">
|
|
||||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
|
||||||
</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">
|
||||||
@@ -344,6 +297,7 @@ export default function AboutCN() {
|
|||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注意:<code>claude-sonnet-4-5</code>{" "}
|
注意:<code>claude-sonnet-4-5</code>{" "}
|
||||||
|
|||||||
@@ -17,18 +17,7 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000) {
|
|
||||||
return `${num / 1000}k`
|
|
||||||
}
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutJA() {
|
export default function AboutJA() {
|
||||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
|
||||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
|
||||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -116,42 +105,6 @@ export default function AboutJA() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
現在の使用制限:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyRequestLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
リクエスト/日
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
トークン/日
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
トークン/分
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="flex items-center gap-3 my-5">
|
|
||||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
|
||||||
</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">
|
||||||
@@ -359,6 +312,7 @@ export default function AboutJA() {
|
|||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
注:<code>claude-sonnet-4-5</code>
|
注:<code>claude-sonnet-4-5</code>
|
||||||
|
|||||||
@@ -17,18 +17,7 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num: number): string {
|
|
||||||
if (num >= 1000) {
|
|
||||||
return `${num / 1000}k`
|
|
||||||
}
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
const dailyRequestLimit = Number(process.env.DAILY_REQUEST_LIMIT) || 20
|
|
||||||
const dailyTokenLimit = Number(process.env.DAILY_TOKEN_LIMIT) || 500000
|
|
||||||
const tpmLimit = Number(process.env.TPM_LIMIT) || 50000
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -118,42 +107,6 @@ export default function About() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Limits */}
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Please note the current usage limits:
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 mb-5">
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyRequestLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
requests/day
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(dailyTokenLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
tokens/day
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/60 rounded-lg">
|
|
||||||
<p className="text-lg font-bold text-amber-600">
|
|
||||||
{formatNumber(tpmLimit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
tokens/min
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="flex items-center gap-3 my-5">
|
|
||||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-amber-300 to-transparent" />
|
|
||||||
</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">
|
||||||
@@ -378,6 +331,7 @@ export default function About() {
|
|||||||
<li>OpenRouter</li>
|
<li>OpenRouter</li>
|
||||||
<li>DeepSeek</li>
|
<li>DeepSeek</li>
|
||||||
<li>SiliconFlow</li>
|
<li>SiliconFlow</li>
|
||||||
|
<li>ModelScope</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-gray-700 mt-4">
|
<p className="text-gray-700 mt-4">
|
||||||
Note that <code>claude-sonnet-4-5</code> has trained on
|
Note that <code>claude-sonnet-4-5</code> has trained on
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { DrawIoEmbed } from "react-drawio"
|
import { DrawIoEmbed } from "react-drawio"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import ChatPanel from "@/components/chat-panel"
|
import ChatPanel from "@/components/chat-panel"
|
||||||
@@ -17,15 +17,8 @@ const drawioBaseUrl =
|
|||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const {
|
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
||||||
drawioRef,
|
useDiagram()
|
||||||
handleDiagramExport,
|
|
||||||
onDrawioLoad,
|
|
||||||
resetDrawioReady,
|
|
||||||
saveDiagramToStorage,
|
|
||||||
showSaveDialog,
|
|
||||||
setShowSaveDialog,
|
|
||||||
} = 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")
|
||||||
@@ -35,33 +28,12 @@ export default function Home() {
|
|||||||
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
|
||||||
const [darkMode, setDarkMode] = useState(false)
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [closeProtection, setCloseProtection] = useState(false)
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const isSavingRef = useRef(false)
|
|
||||||
const mouseOverDrawioRef = useRef(false)
|
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showSaveDialog) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
isSavingRef.current = false
|
|
||||||
}, 1000)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [showSaveDialog])
|
|
||||||
|
|
||||||
// Handle save from draw.io's built-in save button
|
|
||||||
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
|
||||||
// We use mouse position to determine if the user is interacting with draw.io
|
|
||||||
const handleDrawioSave = useCallback(() => {
|
|
||||||
if (!mouseOverDrawioRef.current) return
|
|
||||||
if (isSavingRef.current) return
|
|
||||||
isSavingRef.current = true
|
|
||||||
setShowSaveDialog(true)
|
|
||||||
}, [setShowSaveDialog])
|
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
// Restore saved locale and redirect if needed
|
||||||
@@ -104,24 +76,29 @@ export default function Home() {
|
|||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [pathname, router])
|
}, [pathname, router])
|
||||||
|
|
||||||
const handleDarkModeChange = async () => {
|
const handleDrawioLoad = useCallback(() => {
|
||||||
await saveDiagramToStorage()
|
setIsDrawioReady(true)
|
||||||
|
onDrawioLoad()
|
||||||
|
}, [onDrawioLoad])
|
||||||
|
|
||||||
|
const handleDarkModeChange = () => {
|
||||||
const newValue = !darkMode
|
const newValue = !darkMode
|
||||||
setDarkMode(newValue)
|
setDarkMode(newValue)
|
||||||
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
||||||
document.documentElement.classList.toggle("dark", newValue)
|
document.documentElement.classList.toggle("dark", newValue)
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawioUiChange = async () => {
|
const handleDrawioUiChange = () => {
|
||||||
await saveDiagramToStorage()
|
|
||||||
const newUi = drawioUi === "min" ? "sketch" : "min"
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
||||||
localStorage.setItem("drawio-theme", newUi)
|
localStorage.setItem("drawio-theme", newUi)
|
||||||
setDrawioUi(newUi)
|
setDrawioUi(newUi)
|
||||||
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check mobile - save diagram and reset draw.io before crossing breakpoint
|
// Check mobile - reset draw.io before crossing breakpoint
|
||||||
const isInitialRenderRef = useRef(true)
|
const isInitialRenderRef = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -130,7 +107,7 @@ export default function Home() {
|
|||||||
!isInitialRenderRef.current &&
|
!isInitialRenderRef.current &&
|
||||||
newIsMobile !== isMobileRef.current
|
newIsMobile !== isMobileRef.current
|
||||||
) {
|
) {
|
||||||
saveDiagramToStorage().catch(() => {})
|
setIsDrawioReady(false)
|
||||||
resetDrawioReady()
|
resetDrawioReady()
|
||||||
}
|
}
|
||||||
isMobileRef.current = newIsMobile
|
isMobileRef.current = newIsMobile
|
||||||
@@ -141,7 +118,7 @@ export default function Home() {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener("resize", checkMobile)
|
window.addEventListener("resize", checkMobile)
|
||||||
return () => window.removeEventListener("resize", checkMobile)
|
return () => window.removeEventListener("resize", checkMobile)
|
||||||
}, [saveDiagramToStorage, resetDrawioReady])
|
}, [resetDrawioReady])
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
const toggleChatPanel = () => {
|
||||||
const panel = chatPanelRef.current
|
const panel = chatPanelRef.current
|
||||||
@@ -199,35 +176,36 @@ export default function Home() {
|
|||||||
className={`h-full relative ${
|
className={`h-full relative ${
|
||||||
isMobile ? "p-1" : "p-2"
|
isMobile ? "p-1" : "p-2"
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => {
|
|
||||||
mouseOverDrawioRef.current = true
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
mouseOverDrawioRef.current = false
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded ? (
|
{isLoaded && (
|
||||||
|
<div
|
||||||
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
||||||
|
>
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
key={`${drawioUi}-${darkMode}-${currentLang}`}
|
||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={onDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
onSave={handleDrawioSave}
|
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: true,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
|
noSaveBtn: true,
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark: darkMode,
|
||||||
lang: currentLang,
|
lang: currentLang,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
)}
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
{(!isLoaded || !isDrawioReady) && (
|
||||||
|
<div className="h-full w-full bg-background flex items-center justify-center">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Draw.io panel is loading...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -250,6 +228,13 @@ export default function Home() {
|
|||||||
onExpand={() => setIsChatVisible(true)}
|
onExpand={() => setIsChatVisible(true)}
|
||||||
>
|
>
|
||||||
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
||||||
|
Loading chat...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
isVisible={isChatVisible}
|
isVisible={isChatVisible}
|
||||||
onToggleVisibility={toggleChatPanel}
|
onToggleVisibility={toggleChatPanel}
|
||||||
@@ -260,6 +245,7 @@ export default function Home() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onCloseProtectionChange={setCloseProtection}
|
onCloseProtectionChange={setCloseProtection}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
|
import {
|
||||||
|
isMinimalDiagram,
|
||||||
|
replaceHistoricalToolInputs,
|
||||||
|
validateFileParts,
|
||||||
|
} from "@/lib/chat-helpers"
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -34,93 +39,6 @@ import { getUserIdFromRequest } from "@/lib/user-id"
|
|||||||
|
|
||||||
export const maxDuration = 120
|
export const maxDuration = 120
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
|
||||||
const MAX_FILES = 5
|
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
|
||||||
function validateFileParts(messages: any[]): {
|
|
||||||
valid: boolean
|
|
||||||
error?: string
|
|
||||||
} {
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
|
||||||
const fileParts =
|
|
||||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
|
||||||
if (filePart.url?.startsWith("data:")) {
|
|
||||||
const base64Data = filePart.url.split(",")[1]
|
|
||||||
if (base64Data) {
|
|
||||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
|
||||||
if (sizeInBytes > MAX_FILE_SIZE) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if diagram is minimal/empty
|
|
||||||
function isMinimalDiagram(xml: string): boolean {
|
|
||||||
const stripped = xml.replace(/\s/g, "")
|
|
||||||
return !stripped.includes('id="2"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to replace historical tool call XML with placeholders
|
|
||||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
|
||||||
// Also fixes invalid/undefined inputs from interrupted streaming
|
|
||||||
function replaceHistoricalToolInputs(messages: any[]): any[] {
|
|
||||||
return messages.map((msg) => {
|
|
||||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
const replacedContent = msg.content
|
|
||||||
.map((part: any) => {
|
|
||||||
if (part.type === "tool-call") {
|
|
||||||
const toolName = part.toolName
|
|
||||||
// Fix invalid/undefined inputs from interrupted streaming
|
|
||||||
if (
|
|
||||||
!part.input ||
|
|
||||||
typeof part.input !== "object" ||
|
|
||||||
Object.keys(part.input).length === 0
|
|
||||||
) {
|
|
||||||
// Skip tool calls with invalid inputs entirely
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
toolName === "display_diagram" ||
|
|
||||||
toolName === "edit_diagram"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
input: {
|
|
||||||
placeholder:
|
|
||||||
"[XML content replaced - see current diagram XML in system context]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return part
|
|
||||||
})
|
|
||||||
.filter(Boolean) // Remove null entries (invalid tool calls)
|
|
||||||
return { ...msg, content: replacedContent }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create cached stream response
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`
|
||||||
|
|||||||
154
app/api/parse-url/route.ts
Normal file
154
app/api/parse-url/route.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { extract } from "@extractus/article-extractor"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import TurndownService from "turndown"
|
||||||
|
|
||||||
|
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||||
|
const EXTRACT_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
// SSRF protection - block private/internal addresses
|
||||||
|
function isPrivateUrl(urlString: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString)
|
||||||
|
const hostname = url.hostname.toLowerCase()
|
||||||
|
|
||||||
|
// Block localhost
|
||||||
|
if (
|
||||||
|
hostname === "localhost" ||
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AWS/cloud metadata endpoints
|
||||||
|
if (
|
||||||
|
hostname === "169.254.169.254" ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for private IPv4 ranges
|
||||||
|
const ipv4Match = hostname.match(
|
||||||
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||||
|
)
|
||||||
|
if (ipv4Match) {
|
||||||
|
const [, a, b] = ipv4Match.map(Number)
|
||||||
|
if (a === 10) return true // 10.0.0.0/8
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
||||||
|
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
||||||
|
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
|
||||||
|
if (a === 127) return true // 127.0.0.0/8 (loopback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block common internal hostnames
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".localhost")
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return true // Invalid URL - block it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { url } = await req.json()
|
||||||
|
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "URL is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid URL format" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF protection
|
||||||
|
if (isPrivateUrl(url)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Cannot access private/internal URLs" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract article content with timeout to avoid tying up server resources
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
}, EXTRACT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let article
|
||||||
|
try {
|
||||||
|
article = await extract(url, undefined, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Timed out while fetching URL content" },
|
||||||
|
{ status: 504 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!article || !article.content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not extract content from URL" },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert HTML to Markdown
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: "atx",
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove unwanted elements before conversion
|
||||||
|
turndownService.remove(["script", "style", "iframe", "noscript"])
|
||||||
|
|
||||||
|
const markdown = turndownService.turndown(article.content)
|
||||||
|
|
||||||
|
// Check content length
|
||||||
|
if (markdown.length > MAX_CONTENT_LENGTH) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Content exceeds ${MAX_CONTENT_LENGTH / 1000}k character limit (${(markdown.length / 1000).toFixed(1)}k chars)`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
title: article.title || "Untitled",
|
||||||
|
content: markdown,
|
||||||
|
charCount: markdown.length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("URL extraction error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch or parse URL content" },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,16 +251,98 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// ByteDance Doubao uses DeepSeek-compatible API
|
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
|
||||||
|
const doubaoBaseUrl =
|
||||||
|
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("deepseek") ||
|
||||||
|
lowerModelId.includes("kimi")
|
||||||
|
) {
|
||||||
const doubao = createDeepSeek({
|
const doubao = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL:
|
baseURL: doubaoBaseUrl,
|
||||||
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
|
||||||
})
|
})
|
||||||
model = doubao(modelId)
|
model = doubao(modelId)
|
||||||
|
} else {
|
||||||
|
const doubao = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: doubaoBaseUrl,
|
||||||
|
})
|
||||||
|
model = doubao.chat(modelId)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "modelscope": {
|
||||||
|
const baseURL =
|
||||||
|
baseUrl || "https://api-inference.modelscope.cn/v1"
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseURL}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: modelId,
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "Say 'OK'" },
|
||||||
|
],
|
||||||
|
max_tokens: 20,
|
||||||
|
stream: true,
|
||||||
|
enable_thinking: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`ModelScope API error (${response.status}): ${errorText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type") || ""
|
||||||
|
const isValidStreamingResponse =
|
||||||
|
response.status === 200 &&
|
||||||
|
(contentType.includes("text/event-stream") ||
|
||||||
|
contentType.includes("application/json"))
|
||||||
|
|
||||||
|
if (!isValidStreamingResponse) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response format: ${contentType}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
response.body.cancel().catch(() => {
|
||||||
|
/* Ignore cancellation errors */
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
valid: true,
|
||||||
|
responseTime,
|
||||||
|
note: "ModelScope model validated (using streaming API)",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[validate-model] ModelScope validation failed:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: `Unknown provider: ${provider}` },
|
{ valid: false, error: `Unknown provider: ${provider}` },
|
||||||
|
|||||||
@@ -74,8 +74,8 @@
|
|||||||
--accent: oklch(0.94 0.03 280);
|
--accent: oklch(0.94 0.03 280);
|
||||||
--accent-foreground: oklch(0.35 0.08 270);
|
--accent-foreground: oklch(0.35 0.08 270);
|
||||||
|
|
||||||
/* Coral destructive */
|
/* Muted rose destructive */
|
||||||
--destructive: oklch(0.6 0.2 25);
|
--destructive: oklch(0.45 0.12 10);
|
||||||
|
|
||||||
/* Subtle borders */
|
/* Subtle borders */
|
||||||
--border: oklch(0.92 0.01 260);
|
--border: oklch(0.92 0.01 260);
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
--accent: oklch(0.3 0.04 280);
|
--accent: oklch(0.3 0.04 280);
|
||||||
--accent-foreground: oklch(0.9 0.03 270);
|
--accent-foreground: oklch(0.9 0.03 270);
|
||||||
|
|
||||||
--destructive: oklch(0.65 0.22 25);
|
--destructive: oklch(0.55 0.12 10);
|
||||||
|
|
||||||
--border: oklch(0.28 0.015 260);
|
--border: oklch(0.28 0.015 260);
|
||||||
--input: oklch(0.25 0.015 260);
|
--input: oklch(0.25 0.015 260);
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export const ModelSelectorLogo = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ function ExampleCard({
|
|||||||
export default function ExamplePanel({
|
export default function ExamplePanel({
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
minimal = false,
|
||||||
}: {
|
}: {
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
setFiles: (files: File[]) => void
|
setFiles: (files: File[]) => void
|
||||||
|
minimal?: boolean
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
|
||||||
@@ -120,7 +122,9 @@ export default function ExamplePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-2 animate-fade-in">
|
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
|
||||||
|
{!minimal && (
|
||||||
|
<>
|
||||||
{/* MCP Server Notice */}
|
{/* MCP Server Notice */}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||||
@@ -157,12 +161,16 @@ export default function ExamplePanel({
|
|||||||
{dict.examples.subtitle}
|
{dict.examples.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Examples grid */}
|
{/* Examples grid */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{!minimal && (
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
||||||
{dict.examples.quickExamples}
|
{dict.examples.quickExamples}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<ExampleCard
|
<ExampleCard
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Link,
|
||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
@@ -15,16 +15,17 @@ import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
|||||||
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"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
|
||||||
import { SaveDialog } from "@/components/save-dialog"
|
import { SaveDialog } from "@/components/save-dialog"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { UrlInputDialog } from "@/components/url-input-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
import type { FlattenedModel } from "@/lib/types/model-config"
|
import type { FlattenedModel } from "@/lib/types/model-config"
|
||||||
|
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
|
||||||
import { FilePreviewList } from "./file-preview-list"
|
import { FilePreviewList } from "./file-preview-list"
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
@@ -140,13 +141,14 @@ interface ChatInputProps {
|
|||||||
status: "submitted" | "streaming" | "ready" | "error"
|
status: "submitted" | "streaming" | "ready" | "error"
|
||||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
onClearChat: () => void
|
|
||||||
files?: File[]
|
files?: File[]
|
||||||
onFileChange?: (files: File[]) => void
|
onFileChange?: (files: File[]) => void
|
||||||
pdfData?: Map<
|
pdfData?: Map<
|
||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
|
urlData?: Map<string, UrlData>
|
||||||
|
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||||
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
error?: Error | null
|
error?: Error | null
|
||||||
@@ -163,10 +165,11 @@ export function ChatInput({
|
|||||||
status,
|
status,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onChange,
|
onChange,
|
||||||
onClearChat,
|
|
||||||
files = [],
|
files = [],
|
||||||
onFileChange = () => {},
|
onFileChange = () => {},
|
||||||
pdfData = new Map(),
|
pdfData = new Map(),
|
||||||
|
urlData,
|
||||||
|
onUrlChange,
|
||||||
sessionId,
|
sessionId,
|
||||||
error = null,
|
error = null,
|
||||||
models = [],
|
models = [],
|
||||||
@@ -176,14 +179,19 @@ export function ChatInput({
|
|||||||
onConfigureModels = () => {},
|
onConfigureModels = () => {},
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { diagramHistory, saveDiagramToFile } = useDiagram()
|
const {
|
||||||
|
diagramHistory,
|
||||||
|
saveDiagramToFile,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
} = useDiagram()
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [showClearDialog, setShowClearDialog] = useState(false)
|
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showUrlDialog, setShowUrlDialog] = useState(false)
|
||||||
|
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
|
||||||
// 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")
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
(status === "streaming" || status === "submitted") && !error
|
(status === "streaming" || status === "submitted") && !error
|
||||||
@@ -313,9 +321,48 @@ export function ChatInput({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleUrlExtract = async (url: string) => {
|
||||||
onClearChat()
|
if (!onUrlChange) return
|
||||||
setShowClearDialog(false)
|
|
||||||
|
setIsExtractingUrl(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = urlData
|
||||||
|
? new Map(urlData)
|
||||||
|
: new Map<string, UrlData>()
|
||||||
|
existing.set(url, {
|
||||||
|
url,
|
||||||
|
title: url,
|
||||||
|
content: "",
|
||||||
|
charCount: 0,
|
||||||
|
isExtracting: true,
|
||||||
|
})
|
||||||
|
onUrlChange(existing)
|
||||||
|
|
||||||
|
const data = await extractUrlContent(url)
|
||||||
|
|
||||||
|
const newUrlData = new Map(existing)
|
||||||
|
newUrlData.set(url, data)
|
||||||
|
onUrlChange(newUrlData)
|
||||||
|
|
||||||
|
setShowUrlDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
// Remove the URL from the data map on error
|
||||||
|
const newUrlData = urlData
|
||||||
|
? new Map(urlData)
|
||||||
|
: new Map<string, UrlData>()
|
||||||
|
newUrlData.delete(url)
|
||||||
|
onUrlChange(newUrlData)
|
||||||
|
showErrorToast(
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to extract URL content"}
|
||||||
|
</span>,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsExtractingUrl(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -330,13 +377,23 @@ export function ChatInput({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{/* File previews */}
|
{/* File & URL previews */}
|
||||||
{files.length > 0 && (
|
{(files.length > 0 || (urlData && urlData.size > 0)) && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<FilePreviewList
|
<FilePreviewList
|
||||||
files={files}
|
files={files}
|
||||||
onRemoveFile={handleRemoveFile}
|
onRemoveFile={handleRemoveFile}
|
||||||
pdfData={pdfData}
|
pdfData={pdfData}
|
||||||
|
urlData={urlData}
|
||||||
|
onRemoveUrl={
|
||||||
|
onUrlChange
|
||||||
|
? (url) => {
|
||||||
|
const next = new Map(urlData)
|
||||||
|
next.delete(url)
|
||||||
|
onUrlChange(next)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,36 +410,14 @@ export function ChatInput({
|
|||||||
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
|
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowClearDialog(true)}
|
|
||||||
tooltipContent={dict.chat.clearConversation}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<ResetWarningModal
|
|
||||||
open={showClearDialog}
|
|
||||||
onOpenChange={setShowClearDialog}
|
|
||||||
onClear={handleClear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 overflow-hidden justify-end">
|
|
||||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowHistory(true)}
|
onClick={() => setShowHistory(true)}
|
||||||
disabled={
|
disabled={isDisabled || diagramHistory.length === 0}
|
||||||
isDisabled || diagramHistory.length === 0
|
|
||||||
}
|
|
||||||
tooltipContent={dict.chat.diagramHistory}
|
tooltipContent={dict.chat.diagramHistory}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -413,6 +448,20 @@ export function ChatInput({
|
|||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</ButtonWithTooltip>
|
</ButtonWithTooltip>
|
||||||
|
|
||||||
|
{onUrlChange && (
|
||||||
|
<ButtonWithTooltip
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUrlDialog(true)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
tooltipContent={dict.chat.ExtractURL}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -452,7 +501,6 @@ export function ChatInput({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<HistoryDialog
|
<HistoryDialog
|
||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={setShowHistory}
|
onToggleHistory={setShowHistory}
|
||||||
@@ -461,12 +509,25 @@ export function ChatInput({
|
|||||||
open={showSaveDialog}
|
open={showSaveDialog}
|
||||||
onOpenChange={setShowSaveDialog}
|
onOpenChange={setShowSaveDialog}
|
||||||
onSave={(filename, format) =>
|
onSave={(filename, format) =>
|
||||||
saveDiagramToFile(filename, format, sessionId)
|
saveDiagramToFile(
|
||||||
|
filename,
|
||||||
|
format,
|
||||||
|
sessionId,
|
||||||
|
dict.save.savedSuccessfully,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
defaultFilename={`diagram-${new Date()
|
defaultFilename={`diagram-${new Date()
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10)}`}
|
.slice(0, 10)}`}
|
||||||
/>
|
/>
|
||||||
|
{onUrlChange && (
|
||||||
|
<UrlInputDialog
|
||||||
|
open={showUrlDialog}
|
||||||
|
onOpenChange={setShowUrlDialog}
|
||||||
|
onSubmit={handleUrlExtract}
|
||||||
|
isExtracting={isExtractingUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
Cpu,
|
|
||||||
FileCode,
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -26,6 +25,9 @@ import {
|
|||||||
ReasoningContent,
|
ReasoningContent,
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning"
|
} from "@/components/ai-elements/reasoning"
|
||||||
|
import { ChatLobby } from "@/components/chat/ChatLobby"
|
||||||
|
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||||
|
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
@@ -33,18 +35,9 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
convertToLegalXml,
|
convertToLegalXml,
|
||||||
extractCompleteMxCells,
|
extractCompleteMxCells,
|
||||||
isMxCellXmlComplete,
|
|
||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import ExamplePanel from "./chat-example-panel"
|
|
||||||
import { CodeBlock } from "./code-block"
|
|
||||||
|
|
||||||
interface DiagramOperation {
|
|
||||||
operation: "update" | "add" | "delete"
|
|
||||||
cell_id: string
|
|
||||||
new_xml?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
@@ -58,60 +51,10 @@ function getCompleteOperations(
|
|||||||
["update", "add", "delete"].includes(op.operation) &&
|
["update", "add", "delete"].includes(op.operation) &&
|
||||||
typeof op.cell_id === "string" &&
|
typeof op.cell_id === "string" &&
|
||||||
op.cell_id.length > 0 &&
|
op.cell_id.length > 0 &&
|
||||||
// delete doesn't need new_xml, update/add do
|
|
||||||
(op.operation === "delete" || typeof op.new_xml === "string"),
|
(op.operation === "delete" || typeof op.new_xml === "string"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool part interface for type safety
|
|
||||||
interface ToolPartLike {
|
|
||||||
type: string
|
|
||||||
toolCallId: string
|
|
||||||
state?: string
|
|
||||||
input?: {
|
|
||||||
xml?: string
|
|
||||||
operations?: DiagramOperation[]
|
|
||||||
} & Record<string, unknown>
|
|
||||||
output?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{operations.map((op, index) => (
|
|
||||||
<div
|
|
||||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
|
||||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
|
||||||
>
|
|
||||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
|
||||||
op.operation === "delete"
|
|
||||||
? "text-red-600"
|
|
||||||
: op.operation === "add"
|
|
||||||
? "text-green-600"
|
|
||||||
: "text-blue-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{op.operation}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
cell_id: {op.cell_id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{op.new_xml && (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
|
||||||
{op.new_xml}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
|
|
||||||
// Helper to split text content into regular text and file sections (PDF or text files)
|
// Helper to split text content into regular text and file sections (PDF or text files)
|
||||||
@@ -183,6 +126,13 @@ const getUserOriginalText = (message: UIMessage): string => {
|
|||||||
return fullText.replace(filePattern, "").trim()
|
return fullText.replace(filePattern, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
updatedAt: number
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
setInput: (input: string) => void
|
setInput: (input: string) => void
|
||||||
@@ -193,6 +143,11 @@ interface ChatMessageDisplayProps {
|
|||||||
onRegenerate?: (messageIndex: number) => void
|
onRegenerate?: (messageIndex: number) => void
|
||||||
onEditMessage?: (messageIndex: number, newText: string) => void
|
onEditMessage?: (messageIndex: number, newText: string) => void
|
||||||
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
status?: "streaming" | "submitted" | "idle" | "error" | "ready"
|
||||||
|
isRestored?: boolean
|
||||||
|
sessions?: SessionMetadata[]
|
||||||
|
onSelectSession?: (id: string) => void
|
||||||
|
onDeleteSession?: (id: string) => void
|
||||||
|
loadedMessageIdsRef?: MutableRefObject<Set<string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
@@ -205,14 +160,33 @@ export function ChatMessageDisplay({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
status = "idle",
|
status = "idle",
|
||||||
|
isRestored = false,
|
||||||
|
sessions = [],
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
loadedMessageIdsRef,
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const scrollTopRef = useRef<HTMLDivElement>(null)
|
||||||
const previousXML = useRef<string>("")
|
const previousXML = useRef<string>("")
|
||||||
const processedToolCalls = processedToolCallsRef
|
const processedToolCalls = processedToolCallsRef
|
||||||
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
// Track the last processed XML per toolCallId to skip redundant processing during streaming
|
||||||
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
// Reset refs when messages become empty (new chat or session switch)
|
||||||
|
// This ensures cached examples work correctly after starting a new session
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
previousXML.current = ""
|
||||||
|
lastProcessedXmlRef.current.clear()
|
||||||
|
// Note: processedToolCalls is passed from parent, so we clear it too
|
||||||
|
processedToolCalls.current.clear()
|
||||||
|
// Scroll to top to show newest history items
|
||||||
|
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
|
||||||
|
}
|
||||||
|
}, [messages.length, processedToolCalls])
|
||||||
// Debounce streaming diagram updates - store pending XML and timeout
|
// Debounce streaming diagram updates - store pending XML and timeout
|
||||||
const pendingXmlRef = useRef<string | null>(null)
|
const pendingXmlRef = useRef<string | null>(null)
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
@@ -347,7 +321,6 @@ export function ChatMessageDisplay({
|
|||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string, showToast = false) => {
|
(xml: string, showToast = false) => {
|
||||||
let currentXml = xml || ""
|
let currentXml = xml || ""
|
||||||
const startTime = performance.now()
|
|
||||||
|
|
||||||
// During streaming (showToast=false), extract only complete mxCell elements
|
// During streaming (showToast=false), extract only complete mxCell elements
|
||||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||||
@@ -371,14 +344,8 @@ export function ChatMessageDisplay({
|
|||||||
const parseError = testDoc.querySelector("parsererror")
|
const parseError = testDoc.querySelector("parsererror")
|
||||||
|
|
||||||
if (parseError) {
|
if (parseError) {
|
||||||
// Use console.warn instead of console.error to avoid triggering
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
// Next.js dev mode error overlay for expected streaming states
|
|
||||||
// (partial XML during streaming is normal and will be fixed by subsequent updates)
|
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
// Only log as error and show toast if this is the final XML
|
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] Malformed XML detected in final output",
|
|
||||||
)
|
|
||||||
toast.error(dict.errors.malformedXml)
|
toast.error(dict.errors.malformedXml)
|
||||||
}
|
}
|
||||||
return // Skip this update
|
return // Skip this update
|
||||||
@@ -392,18 +359,12 @@ export function ChatMessageDisplay({
|
|||||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||||
|
|
||||||
const xmlProcessTime = performance.now() - startTime
|
|
||||||
|
|
||||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||||
// The quick DOM parse check above catches malformed XML
|
// The quick DOM parse check above catches malformed XML
|
||||||
// Full validation runs on final output (showToast=true)
|
// Full validation runs on final output (showToast=true)
|
||||||
if (!showToast) {
|
if (!showToast) {
|
||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
const loadStartTime = performance.now()
|
|
||||||
onDisplayChart(replacedXML, true)
|
onDisplayChart(replacedXML, true)
|
||||||
console.log(
|
|
||||||
`[Streaming] XML processing: ${xmlProcessTime.toFixed(1)}ms, drawio load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,30 +374,12 @@ export function ChatMessageDisplay({
|
|||||||
previousXML.current = convertedXml
|
previousXML.current = convertedXml
|
||||||
// Use fixed XML if available, otherwise use original
|
// Use fixed XML if available, otherwise use original
|
||||||
const xmlToLoad = validation.fixed || replacedXML
|
const xmlToLoad = validation.fixed || replacedXML
|
||||||
if (validation.fixes.length > 0) {
|
|
||||||
console.log(
|
|
||||||
"[ChatMessageDisplay] Auto-fixed XML issues:",
|
|
||||||
validation.fixes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Skip validation in loadDiagram since we already validated above
|
|
||||||
const loadStartTime = performance.now()
|
|
||||||
onDisplayChart(xmlToLoad, true)
|
onDisplayChart(xmlToLoad, true)
|
||||||
console.log(
|
|
||||||
`[Final] XML processing: ${xmlProcessTime.toFixed(1)}ms, validation+load: ${(performance.now() - loadStartTime).toFixed(1)}ms`,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
|
||||||
"[ChatMessageDisplay] XML validation failed:",
|
|
||||||
validation.error,
|
|
||||||
)
|
|
||||||
toast.error(dict.errors.validationFailed)
|
toast.error(dict.errors.validationFailed)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error("Error processing XML:", error)
|
||||||
"[ChatMessageDisplay] Error processing XML:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
// Only show toast if this is the final XML (not during streaming)
|
// Only show toast if this is the final XML (not during streaming)
|
||||||
if (showToast) {
|
if (showToast) {
|
||||||
toast.error(dict.errors.failedToProcess)
|
toast.error(dict.errors.failedToProcess)
|
||||||
@@ -447,8 +390,22 @@ export function ChatMessageDisplay({
|
|||||||
[chartXML, onDisplayChart],
|
[chartXML, onDisplayChart],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Track previous message count to detect bulk loads vs streaming
|
||||||
|
const prevMessageCountRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current && messages.length > 0) {
|
||||||
|
const prevCount = prevMessageCountRef.current
|
||||||
|
const currentCount = messages.length
|
||||||
|
prevMessageCountRef.current = currentCount
|
||||||
|
|
||||||
|
// Bulk load (session restore) - instant scroll, no animation
|
||||||
|
if (prevCount === 0 || currentCount - prevCount > 1) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: "instant" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single message added - smooth scroll
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
@@ -666,202 +623,19 @@ export function ChatMessageDisplay({
|
|||||||
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
// Let the timeouts complete naturally - they're harmless if component unmounts.
|
||||||
}, [messages, handleDisplayChart, chartXML])
|
}, [messages, handleDisplayChart, chartXML])
|
||||||
|
|
||||||
const renderToolPart = (part: ToolPartLike) => {
|
|
||||||
const callId = part.toolCallId
|
|
||||||
const { state, input, output } = part
|
|
||||||
const isExpanded = expandedTools[callId] ?? true
|
|
||||||
const toolName = part.type?.replace("tool-", "")
|
|
||||||
const isCopied = copiedToolCallId === callId
|
|
||||||
|
|
||||||
const toggleExpanded = () => {
|
|
||||||
setExpandedTools((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[callId]: !isExpanded,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToolDisplayName = (name: string) => {
|
|
||||||
switch (name) {
|
|
||||||
case "display_diagram":
|
|
||||||
return "Generate Diagram"
|
|
||||||
case "edit_diagram":
|
|
||||||
return "Edit Diagram"
|
|
||||||
case "get_shape_library":
|
|
||||||
return "Get Shape Library"
|
|
||||||
default:
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
let textToCopy = ""
|
|
||||||
|
|
||||||
if (input && typeof input === "object") {
|
|
||||||
if (input.xml) {
|
|
||||||
textToCopy = input.xml
|
|
||||||
} else if (
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations)
|
|
||||||
) {
|
|
||||||
textToCopy = JSON.stringify(input.operations, null, 2)
|
|
||||||
} else if (Object.keys(input).length > 0) {
|
|
||||||
textToCopy = JSON.stringify(input, null, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
typeof output === "string"
|
|
||||||
) {
|
|
||||||
textToCopy = output
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textToCopy) {
|
|
||||||
copyMessageToClipboard(callId, textToCopy, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={callId}
|
|
||||||
className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
|
||||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-foreground/80">
|
|
||||||
{getToolDisplayName(toolName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{state === "input-streaming" && (
|
|
||||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
{state === "output-available" && (
|
|
||||||
<>
|
|
||||||
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
|
||||||
{dict.tools.complete}
|
|
||||||
</span>
|
|
||||||
{isExpanded && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
title={
|
|
||||||
copiedToolCallId === callId
|
|
||||||
? dict.chat.copied
|
|
||||||
: copyFailedToolCallId ===
|
|
||||||
callId
|
|
||||||
? dict.chat.failedToCopy
|
|
||||||
: dict.chat.copyResponse
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<Check className="w-4 h-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
// Check if this is a truncation (incomplete XML) vs real error
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return isTruncated ? (
|
|
||||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
|
||||||
Truncated
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{input && Object.keys(input).length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{input && isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
|
||||||
{typeof input === "object" && input.xml ? (
|
|
||||||
<CodeBlock code={input.xml} language="xml" />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
input.operations &&
|
|
||||||
Array.isArray(input.operations) ? (
|
|
||||||
<OperationsDisplay operations={input.operations} />
|
|
||||||
) : typeof input === "object" &&
|
|
||||||
Object.keys(input).length > 0 ? (
|
|
||||||
<CodeBlock
|
|
||||||
code={JSON.stringify(input, null, 2)}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{output &&
|
|
||||||
state === "output-error" &&
|
|
||||||
(() => {
|
|
||||||
const isTruncated =
|
|
||||||
(toolName === "display_diagram" ||
|
|
||||||
toolName === "append_diagram") &&
|
|
||||||
!isMxCellXmlComplete(input?.xml)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
|
||||||
>
|
|
||||||
{isTruncated
|
|
||||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
|
||||||
: output}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{/* Show get_shape_library output on success */}
|
|
||||||
{output &&
|
|
||||||
toolName === "get_shape_library" &&
|
|
||||||
state === "output-available" &&
|
|
||||||
isExpanded && (
|
|
||||||
<div className="px-4 py-3 border-t border-border/40">
|
|
||||||
<div className="text-xs text-muted-foreground mb-2">
|
|
||||||
Library loaded (
|
|
||||||
{typeof output === "string" ? output.length : 0}{" "}
|
|
||||||
chars)
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
|
||||||
{typeof output === "string"
|
|
||||||
? output.substring(0, 800) +
|
|
||||||
(output.length > 800 ? "\n..." : "")
|
|
||||||
: String(output)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||||
{messages.length === 0 ? (
|
<div ref={scrollTopRef} />
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
{messages.length === 0 && isRestored ? (
|
||||||
) : (
|
<ChatLobby
|
||||||
|
sessions={sessions}
|
||||||
|
onSelectSession={onSelectSession || (() => {})}
|
||||||
|
onDeleteSession={onDeleteSession}
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
dict={dict}
|
||||||
|
/>
|
||||||
|
) : messages.length === 0 ? null : (
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 px-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText =
|
const userMessageText =
|
||||||
@@ -881,13 +655,21 @@ export function ChatMessageDisplay({
|
|||||||
.slice(messageIndex + 1)
|
.slice(messageIndex + 1)
|
||||||
.every((m) => m.role !== "user"))
|
.every((m) => m.role !== "user"))
|
||||||
const isEditing = editingMessageId === message.id
|
const isEditing = editingMessageId === message.id
|
||||||
|
// Skip animation for loaded messages (from session restore)
|
||||||
|
const isRestoredMessage =
|
||||||
|
loadedMessageIdsRef?.current.has(message.id) ??
|
||||||
|
false
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`}
|
||||||
style={{
|
style={
|
||||||
|
isRestoredMessage
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
animationDelay: `${messageIndex * 50}ms`,
|
animationDelay: `${messageIndex * 50}ms`,
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{message.role === "user" &&
|
{message.role === "user" &&
|
||||||
userMessageText &&
|
userMessageText &&
|
||||||
@@ -984,6 +766,9 @@ export function ChatMessageDisplay({
|
|||||||
isStreaming={
|
isStreaming={
|
||||||
isStreamingReasoning
|
isStreamingReasoning
|
||||||
}
|
}
|
||||||
|
defaultOpen={
|
||||||
|
!isRestoredMessage
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
@@ -1126,9 +911,30 @@ export function ChatMessageDisplay({
|
|||||||
return groups.map(
|
return groups.map(
|
||||||
(group, groupIndex) => {
|
(group, groupIndex) => {
|
||||||
if (group.type === "tool") {
|
if (group.type === "tool") {
|
||||||
return renderToolPart(
|
return (
|
||||||
|
<ToolCallCard
|
||||||
|
key={`${message.id}-tool-${group.startIndex}`}
|
||||||
|
part={
|
||||||
group
|
group
|
||||||
.parts[0] as ToolPartLike,
|
.parts[0] as ToolPartLike
|
||||||
|
}
|
||||||
|
expandedTools={
|
||||||
|
expandedTools
|
||||||
|
}
|
||||||
|
setExpandedTools={
|
||||||
|
setExpandedTools
|
||||||
|
}
|
||||||
|
onCopy={
|
||||||
|
copyMessageToClipboard
|
||||||
|
}
|
||||||
|
copiedToolCallId={
|
||||||
|
copiedToolCallId
|
||||||
|
}
|
||||||
|
copyFailedToolCallId={
|
||||||
|
copyFailedToolCallId
|
||||||
|
}
|
||||||
|
dict={dict}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,34 +9,40 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
import { flushSync } from "react-dom"
|
import { flushSync } from "react-dom"
|
||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
|
||||||
import { ChatInput } from "@/components/chat-input"
|
import { ChatInput } from "@/components/chat-input"
|
||||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal"
|
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useDiagram } from "@/contexts/diagram-context"
|
import { useDiagram } from "@/contexts/diagram-context"
|
||||||
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
|
||||||
|
import { useSessionManager } from "@/hooks/use-session-manager"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||||
|
import { sanitizeMessages } from "@/lib/session-storage"
|
||||||
|
import type { UrlData } from "@/lib/url-utils"
|
||||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||||
import { formatXML } from "@/lib/utils"
|
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
|
||||||
import { ChatMessageDisplay } from "./chat-message-display"
|
import { ChatMessageDisplay } from "./chat-message-display"
|
||||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||||
|
|
||||||
// localStorage keys for persistence
|
// localStorage keys for persistence
|
||||||
const STORAGE_MESSAGES_KEY = "next-ai-draw-io-messages"
|
|
||||||
const STORAGE_XML_SNAPSHOTS_KEY = "next-ai-draw-io-xml-snapshots"
|
|
||||||
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
|
||||||
export const STORAGE_DIAGRAM_XML_KEY = "next-ai-draw-io-diagram-xml"
|
|
||||||
|
|
||||||
// sessionStorage keys
|
// sessionStorage keys
|
||||||
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
|
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
|
||||||
@@ -113,10 +119,17 @@ export default function ChatPanel({
|
|||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
resolverRef,
|
resolverRef,
|
||||||
chartXML,
|
chartXML,
|
||||||
|
latestSvg,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
|
getThumbnailSvg,
|
||||||
|
diagramHistory,
|
||||||
|
setDiagramHistory,
|
||||||
} = useDiagram()
|
} = useDiagram()
|
||||||
|
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const urlSessionId = searchParams.get("session")
|
||||||
|
|
||||||
const onFetchChart = (saveToHistory = true) => {
|
const onFetchChart = (saveToHistory = true) => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
@@ -146,17 +159,21 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// File processing using extracted hook
|
// File processing using extracted hook
|
||||||
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
|
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
|
||||||
|
const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())
|
||||||
|
|
||||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
|
||||||
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
|
||||||
|
|
||||||
// Model configuration hook
|
// Model configuration hook
|
||||||
const modelConfig = useModelConfig()
|
const modelConfig = useModelConfig()
|
||||||
|
|
||||||
|
// Session manager for chat history (pass URL session ID for restoration)
|
||||||
|
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
|
||||||
|
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
|
||||||
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
|
||||||
const [tpmLimit, setTpmLimit] = useState(0)
|
const [tpmLimit, setTpmLimit] = useState(0)
|
||||||
const [showNewChatDialog, setShowNewChatDialog] = useState(false)
|
|
||||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
const [minimalStyle, setMinimalStyle] = 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)
|
||||||
@@ -201,13 +218,37 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Flag to track if we've restored from localStorage
|
// Flag to track if we've restored from localStorage
|
||||||
const hasRestoredRef = useRef(false)
|
const hasRestoredRef = useRef(false)
|
||||||
|
const [isRestored, setIsRestored] = useState(false)
|
||||||
|
|
||||||
|
// Track previous isVisible to only animate when toggling (not on page load)
|
||||||
|
const prevIsVisibleRef = useRef(isVisible)
|
||||||
|
const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only animate when visibility changes from false to true (not on initial load)
|
||||||
|
if (!prevIsVisibleRef.current && isVisible) {
|
||||||
|
setShouldAnimatePanel(true)
|
||||||
|
}
|
||||||
|
prevIsVisibleRef.current = isVisible
|
||||||
|
}, [isVisible])
|
||||||
|
|
||||||
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
|
||||||
const chartXMLRef = useRef(chartXML)
|
const chartXMLRef = useRef(chartXML)
|
||||||
|
// Track session ID that was loaded without a diagram (to prevent thumbnail contamination)
|
||||||
|
const justLoadedSessionIdRef = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartXMLRef.current = chartXML
|
chartXMLRef.current = chartXML
|
||||||
|
// Clear the no-diagram flag when a diagram is generated
|
||||||
|
if (chartXML) {
|
||||||
|
justLoadedSessionIdRef.current = null
|
||||||
|
}
|
||||||
}, [chartXML])
|
}, [chartXML])
|
||||||
|
|
||||||
|
// Ref to track latest SVG for thumbnail generation
|
||||||
|
const latestSvgRef = useRef(latestSvg)
|
||||||
|
useEffect(() => {
|
||||||
|
latestSvgRef.current = latestSvg
|
||||||
|
}, [latestSvg])
|
||||||
|
|
||||||
// Ref to track consecutive auto-retry count (reset on user action)
|
// Ref to track consecutive auto-retry count (reset on user action)
|
||||||
const autoRetryCountRef = useRef(0)
|
const autoRetryCountRef = useRef(0)
|
||||||
// Ref to track continuation retry count (for truncation handling)
|
// Ref to track continuation retry count (for truncation handling)
|
||||||
@@ -289,32 +330,6 @@ export default function ChatPanel({
|
|||||||
// Silence access code error in console since it's handled by UI
|
// Silence access code error in console since it's handled by UI
|
||||||
if (!error.message.includes("Invalid or missing access code")) {
|
if (!error.message.includes("Invalid or missing access code")) {
|
||||||
console.error("Chat error:", error)
|
console.error("Chat error:", error)
|
||||||
// Debug: Log messages structure when error occurs
|
|
||||||
console.log("[onError] messages count:", messages.length)
|
|
||||||
messages.forEach((msg, idx) => {
|
|
||||||
console.log(`[onError] Message ${idx}:`, {
|
|
||||||
role: msg.role,
|
|
||||||
partsCount: msg.parts?.length,
|
|
||||||
})
|
|
||||||
if (msg.parts) {
|
|
||||||
msg.parts.forEach((part: any, partIdx: number) => {
|
|
||||||
console.log(
|
|
||||||
`[onError] Part ${partIdx}:`,
|
|
||||||
JSON.stringify({
|
|
||||||
type: part.type,
|
|
||||||
toolName: part.toolName,
|
|
||||||
hasInput: !!part.input,
|
|
||||||
inputType: typeof part.input,
|
|
||||||
inputKeys:
|
|
||||||
part.input &&
|
|
||||||
typeof part.input === "object"
|
|
||||||
? Object.keys(part.input)
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate technical errors into user-friendly messages
|
// Translate technical errors into user-friendly messages
|
||||||
@@ -360,15 +375,7 @@ export default function ChatPanel({
|
|||||||
setShowSettingsDialog(true)
|
setShowSettingsDialog(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish: ({ message }) => {
|
onFinish: () => {},
|
||||||
// Track actual token usage from server metadata
|
|
||||||
const metadata = message?.metadata as
|
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
// DEBUG: Log finish reason to diagnose truncation
|
|
||||||
console.log("[onFinish] finishReason:", metadata?.finishReason)
|
|
||||||
},
|
|
||||||
sendAutomaticallyWhen: ({ messages }) => {
|
sendAutomaticallyWhen: ({ messages }) => {
|
||||||
const isInContinuationMode = partialXmlRef.current.length > 0
|
const isInContinuationMode = partialXmlRef.current.length > 0
|
||||||
|
|
||||||
@@ -426,58 +433,199 @@ export default function ChatPanel({
|
|||||||
messagesRef.current = messages
|
messagesRef.current = messages
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
// Track last synced session ID to detect external changes (e.g., URL back/forward)
|
||||||
|
const lastSyncedSessionIdRef = useRef<string | null>(null)
|
||||||
|
|
||||||
// Restore messages and XML snapshots from localStorage on mount
|
// Helper: Sync UI state with session data (eliminates duplication)
|
||||||
useEffect(() => {
|
// Track message IDs that are being loaded from session (to skip animations/scroll)
|
||||||
|
const loadedMessageIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
// Track when session was just loaded (to skip auto-save on load)
|
||||||
|
const justLoadedSessionRef = useRef(false)
|
||||||
|
|
||||||
|
const syncUIWithSession = useCallback(
|
||||||
|
(
|
||||||
|
data: {
|
||||||
|
messages: unknown[]
|
||||||
|
xmlSnapshots: [number, string][]
|
||||||
|
diagramXml: string
|
||||||
|
diagramHistory?: { svg: string; xml: string }[]
|
||||||
|
} | null,
|
||||||
|
) => {
|
||||||
|
const hasRealDiagram = isRealDiagram(data?.diagramXml)
|
||||||
|
if (data) {
|
||||||
|
// Mark all message IDs as loaded from session
|
||||||
|
const messageIds = (data.messages as any[]).map(
|
||||||
|
(m: any) => m.id,
|
||||||
|
)
|
||||||
|
loadedMessageIdsRef.current = new Set(messageIds)
|
||||||
|
setMessages(data.messages as any)
|
||||||
|
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
|
||||||
|
if (hasRealDiagram) {
|
||||||
|
onDisplayChart(data.diagramXml, true)
|
||||||
|
chartXMLRef.current = data.diagramXml
|
||||||
|
} else {
|
||||||
|
clearDiagram()
|
||||||
|
// Clear refs to prevent stale data from being saved
|
||||||
|
chartXMLRef.current = ""
|
||||||
|
latestSvgRef.current = ""
|
||||||
|
}
|
||||||
|
setDiagramHistory(data.diagramHistory || [])
|
||||||
|
} else {
|
||||||
|
loadedMessageIdsRef.current = new Set()
|
||||||
|
setMessages([])
|
||||||
|
xmlSnapshotsRef.current.clear()
|
||||||
|
clearDiagram()
|
||||||
|
// Clear refs to prevent stale data from being saved
|
||||||
|
chartXMLRef.current = ""
|
||||||
|
latestSvgRef.current = ""
|
||||||
|
setDiagramHistory([])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setMessages, onDisplayChart, clearDiagram, setDiagramHistory],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper: Build session data object for saving (eliminates duplication)
|
||||||
|
const buildSessionData = useCallback(
|
||||||
|
async (options: { withThumbnail?: boolean } = {}) => {
|
||||||
|
const currentDiagramXml = chartXMLRef.current || ""
|
||||||
|
// Only capture thumbnail if there's a meaningful diagram (not just empty template)
|
||||||
|
const hasRealDiagram = isRealDiagram(currentDiagramXml)
|
||||||
|
let thumbnailDataUrl: string | undefined
|
||||||
|
if (hasRealDiagram && options.withThumbnail) {
|
||||||
|
const freshThumb = await getThumbnailSvg()
|
||||||
|
if (freshThumb) {
|
||||||
|
latestSvgRef.current = freshThumb
|
||||||
|
thumbnailDataUrl = freshThumb
|
||||||
|
} else if (latestSvgRef.current) {
|
||||||
|
// Use cached thumbnail only if we have a real diagram
|
||||||
|
thumbnailDataUrl = latestSvgRef.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messages: sanitizeMessages(messagesRef.current),
|
||||||
|
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
|
||||||
|
diagramXml: currentDiagramXml,
|
||||||
|
thumbnailDataUrl,
|
||||||
|
diagramHistory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[diagramHistory, getThumbnailSvg],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore messages and XML snapshots from session manager on mount
|
||||||
|
// This effect syncs with the session manager's loaded session
|
||||||
|
useLayoutEffect(() => {
|
||||||
if (hasRestoredRef.current) return
|
if (hasRestoredRef.current) return
|
||||||
|
if (sessionManager.isLoading) return // Wait for session manager to load
|
||||||
|
|
||||||
hasRestoredRef.current = true
|
hasRestoredRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Restore messages
|
const currentSession = sessionManager.currentSession
|
||||||
const savedMessages = localStorage.getItem(STORAGE_MESSAGES_KEY)
|
if (currentSession && currentSession.messages.length > 0) {
|
||||||
if (savedMessages) {
|
// Restore from session manager (IndexedDB)
|
||||||
const parsed = JSON.parse(savedMessages)
|
justLoadedSessionRef.current = true
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
syncUIWithSession(currentSession)
|
||||||
setMessages(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore XML snapshots
|
|
||||||
const savedSnapshots = localStorage.getItem(
|
|
||||||
STORAGE_XML_SNAPSHOTS_KEY,
|
|
||||||
)
|
|
||||||
if (savedSnapshots) {
|
|
||||||
const parsed = JSON.parse(savedSnapshots)
|
|
||||||
xmlSnapshotsRef.current = new Map(parsed)
|
|
||||||
}
|
}
|
||||||
|
// Initialize lastSyncedSessionIdRef to prevent sync effect from firing immediately
|
||||||
|
lastSyncedSessionIdRef.current = sessionManager.currentSessionId
|
||||||
|
// Note: Migration from old localStorage format is handled by session-storage.ts
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to restore from localStorage:", error)
|
console.error("Failed to restore session:", error)
|
||||||
// On complete failure, clear storage to allow recovery
|
|
||||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
|
||||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
|
||||||
toast.error(dict.errors.sessionCorrupted)
|
toast.error(dict.errors.sessionCorrupted)
|
||||||
|
} finally {
|
||||||
|
setIsRestored(true)
|
||||||
}
|
}
|
||||||
}, [setMessages])
|
}, [
|
||||||
|
sessionManager.isLoading,
|
||||||
|
sessionManager.currentSession,
|
||||||
|
syncUIWithSession,
|
||||||
|
dict.errors.sessionCorrupted,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Sync UI when session changes externally (e.g., URL navigation via back/forward)
|
||||||
|
// This handles changes AFTER initial restore
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestored) return // Wait for initial restore to complete
|
||||||
|
if (!sessionManager.isAvailable) return
|
||||||
|
|
||||||
|
const newSessionId = sessionManager.currentSessionId
|
||||||
|
const newSession = sessionManager.currentSession
|
||||||
|
|
||||||
|
// Skip if session ID hasn't changed (our own saves don't change the ID)
|
||||||
|
if (newSessionId === lastSyncedSessionIdRef.current) return
|
||||||
|
|
||||||
|
// Update last synced ID
|
||||||
|
lastSyncedSessionIdRef.current = newSessionId
|
||||||
|
|
||||||
|
// Sync UI with new session
|
||||||
|
if (newSession && newSession.messages.length > 0) {
|
||||||
|
justLoadedSessionRef.current = true
|
||||||
|
syncUIWithSession(newSession)
|
||||||
|
} else if (!newSession) {
|
||||||
|
syncUIWithSession(null)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isRestored,
|
||||||
|
sessionManager.isAvailable,
|
||||||
|
sessionManager.currentSessionId,
|
||||||
|
sessionManager.currentSession,
|
||||||
|
syncUIWithSession,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Save messages to session manager (debounced, only when not streaming)
|
||||||
|
// Destructure stable values to avoid effect re-running on every render
|
||||||
|
const {
|
||||||
|
isAvailable: sessionIsAvailable,
|
||||||
|
currentSessionId,
|
||||||
|
saveCurrentSession,
|
||||||
|
} = sessionManager
|
||||||
|
|
||||||
|
// Use ref for saveCurrentSession to avoid infinite loop
|
||||||
|
// (saveCurrentSession changes after each save, which would re-trigger the effect)
|
||||||
|
const saveCurrentSessionRef = useRef(saveCurrentSession)
|
||||||
|
saveCurrentSessionRef.current = saveCurrentSession
|
||||||
|
|
||||||
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasRestoredRef.current) return
|
if (!hasRestoredRef.current) return
|
||||||
|
if (!sessionIsAvailable) return
|
||||||
|
// Only save when not actively streaming to avoid write storms
|
||||||
|
if (status === "streaming" || status === "submitted") return
|
||||||
|
|
||||||
|
// Skip auto-save if session was just loaded (to prevent re-ordering)
|
||||||
|
if (justLoadedSessionRef.current) {
|
||||||
|
justLoadedSessionRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear any pending save
|
// Clear any pending save
|
||||||
if (localStorageDebounceRef.current) {
|
if (localStorageDebounceRef.current) {
|
||||||
clearTimeout(localStorageDebounceRef.current)
|
clearTimeout(localStorageDebounceRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture current session ID at schedule time to verify at save time
|
||||||
|
const scheduledForSessionId = currentSessionId
|
||||||
|
// Capture whether there's a REAL diagram NOW (not just empty template)
|
||||||
|
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
|
||||||
|
// Check if this session was just loaded without a diagram
|
||||||
|
const isNodiagramSession =
|
||||||
|
justLoadedSessionIdRef.current === scheduledForSessionId
|
||||||
|
|
||||||
// Debounce: save after 1 second of no changes
|
// Debounce: save after 1 second of no changes
|
||||||
localStorageDebounceRef.current = setTimeout(() => {
|
localStorageDebounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
if (messages.length > 0) {
|
||||||
STORAGE_MESSAGES_KEY,
|
const sessionData = await buildSessionData({
|
||||||
JSON.stringify(messages),
|
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
|
||||||
|
withThumbnail: hasDiagramNow && !isNodiagramSession,
|
||||||
|
})
|
||||||
|
await saveCurrentSessionRef.current(
|
||||||
|
sessionData,
|
||||||
|
scheduledForSessionId,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save messages to localStorage:", error)
|
console.error("Failed to save session:", error)
|
||||||
}
|
}
|
||||||
}, LOCAL_STORAGE_DEBOUNCE_MS)
|
}, LOCAL_STORAGE_DEBOUNCE_MS)
|
||||||
|
|
||||||
@@ -487,63 +635,62 @@ export default function ChatPanel({
|
|||||||
clearTimeout(localStorageDebounceRef.current)
|
clearTimeout(localStorageDebounceRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [
|
||||||
|
messages,
|
||||||
|
status,
|
||||||
|
sessionIsAvailable,
|
||||||
|
currentSessionId,
|
||||||
|
buildSessionData,
|
||||||
|
])
|
||||||
|
|
||||||
// Save XML snapshots to localStorage whenever they change
|
// Update URL when a new session is created (first message sent)
|
||||||
const saveXmlSnapshots = useCallback(() => {
|
useEffect(() => {
|
||||||
try {
|
if (sessionManager.currentSessionId && !urlSessionId) {
|
||||||
const snapshotsArray = Array.from(xmlSnapshotsRef.current.entries())
|
// A session was created but URL doesn't have the session param yet
|
||||||
localStorage.setItem(
|
router.replace(`?session=${sessionManager.currentSessionId}`, {
|
||||||
STORAGE_XML_SNAPSHOTS_KEY,
|
scroll: false,
|
||||||
JSON.stringify(snapshotsArray),
|
})
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"Failed to save XML snapshots to localStorage:",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [sessionManager.currentSessionId, urlSessionId, router])
|
||||||
|
|
||||||
// Save session ID to localStorage
|
// Save session ID to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
|
// Save session when page becomes hidden (tab switch, close, navigate away)
|
||||||
|
// This is more reliable than beforeunload for async IndexedDB operations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current) {
|
if (!sessionManager.isAvailable) return
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
|
||||||
}
|
|
||||||
}, [messages])
|
|
||||||
|
|
||||||
// Save state right before page unload (refresh/close)
|
const handleVisibilityChange = async () => {
|
||||||
useEffect(() => {
|
if (
|
||||||
const handleBeforeUnload = () => {
|
document.visibilityState === "hidden" &&
|
||||||
|
messagesRef.current.length > 0
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
// Attempt to save session - browser may not wait for completion
|
||||||
STORAGE_MESSAGES_KEY,
|
// Skip thumbnail capture as it may not complete in time
|
||||||
JSON.stringify(messagesRef.current),
|
const sessionData = await buildSessionData({
|
||||||
)
|
withThumbnail: false,
|
||||||
localStorage.setItem(
|
})
|
||||||
STORAGE_XML_SNAPSHOTS_KEY,
|
await sessionManager.saveCurrentSession(sessionData)
|
||||||
JSON.stringify(
|
|
||||||
Array.from(xmlSnapshotsRef.current.entries()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const xml = chartXMLRef.current
|
|
||||||
if (xml && xml.length > 300) {
|
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xml)
|
|
||||||
}
|
|
||||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, sessionId)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to persist state before unload:", error)
|
console.error(
|
||||||
|
"Failed to save session on visibility change:",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload)
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
return () =>
|
return () =>
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload)
|
document.removeEventListener(
|
||||||
}, [sessionId])
|
"visibilitychange",
|
||||||
|
handleVisibilityChange,
|
||||||
|
)
|
||||||
|
}, [sessionManager, buildSessionData])
|
||||||
|
|
||||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -565,6 +712,8 @@ export default function ChatPanel({
|
|||||||
input,
|
input,
|
||||||
files,
|
files,
|
||||||
pdfData,
|
pdfData,
|
||||||
|
undefined,
|
||||||
|
urlData,
|
||||||
)
|
)
|
||||||
|
|
||||||
setMessages([
|
setMessages([
|
||||||
@@ -590,6 +739,7 @@ export default function ChatPanel({
|
|||||||
setInput("")
|
setInput("")
|
||||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||||
setFiles([])
|
setFiles([])
|
||||||
|
setUrlData(new Map())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,6 +760,7 @@ export default function ChatPanel({
|
|||||||
files,
|
files,
|
||||||
pdfData,
|
pdfData,
|
||||||
parts,
|
parts,
|
||||||
|
urlData,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add the combined text as the first part
|
// Add the combined text as the first part
|
||||||
@@ -627,7 +778,6 @@ export default function ChatPanel({
|
|||||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||||
const messageIndex = messages.length
|
const messageIndex = messages.length
|
||||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||||
saveXmlSnapshots()
|
|
||||||
|
|
||||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||||
|
|
||||||
@@ -635,36 +785,105 @@ export default function ChatPanel({
|
|||||||
setInput("")
|
setInput("")
|
||||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||||
setFiles([])
|
setFiles([])
|
||||||
|
setUrlData(new Map())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching chart data:", error)
|
console.error("Error fetching chart data:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
// Handle session switching from history dropdown
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
if (!sessionManager.isAvailable) return
|
||||||
|
|
||||||
|
// Save current session before switching
|
||||||
|
if (messages.length > 0) {
|
||||||
|
const sessionData = await buildSessionData({
|
||||||
|
withThumbnail: true,
|
||||||
|
})
|
||||||
|
await sessionManager.saveCurrentSession(sessionData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to selected session
|
||||||
|
const sessionData = await sessionManager.switchSession(sessionId)
|
||||||
|
if (sessionData) {
|
||||||
|
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
|
||||||
|
justLoadedSessionRef.current = true
|
||||||
|
|
||||||
|
// CRITICAL: Update latestSvgRef with the NEW session's thumbnail
|
||||||
|
// This prevents stale thumbnail from previous session being used by auto-save
|
||||||
|
latestSvgRef.current = sessionData.thumbnailDataUrl || ""
|
||||||
|
|
||||||
|
// Track if this session has no real diagram - to prevent thumbnail contamination
|
||||||
|
if (!hasRealDiagram) {
|
||||||
|
justLoadedSessionIdRef.current = sessionId
|
||||||
|
} else {
|
||||||
|
justLoadedSessionIdRef.current = null
|
||||||
|
}
|
||||||
|
syncUIWithSession(sessionData)
|
||||||
|
router.replace(`?session=${sessionId}`, { scroll: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle session deletion from history dropdown
|
||||||
|
const handleDeleteSession = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
if (!sessionManager.isAvailable) return
|
||||||
|
const result = await sessionManager.deleteSession(sessionId)
|
||||||
|
|
||||||
|
if (result.wasCurrentSession) {
|
||||||
|
// Deleted current session - clear UI and URL
|
||||||
|
syncUIWithSession(null)
|
||||||
|
router.replace(window.location.pathname, { scroll: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionManager, syncUIWithSession, router],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNewChat = useCallback(async () => {
|
||||||
|
// Save current session before creating new one
|
||||||
|
if (sessionManager.isAvailable && messages.length > 0) {
|
||||||
|
const sessionData = await buildSessionData({ withThumbnail: true })
|
||||||
|
await sessionManager.saveCurrentSession(sessionData)
|
||||||
|
// Refresh sessions list to ensure dropdown shows the saved session
|
||||||
|
await sessionManager.refreshSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear session manager state BEFORE clearing URL to prevent race condition
|
||||||
|
// (otherwise the URL update effect would restore the old session URL)
|
||||||
|
sessionManager.clearCurrentSession()
|
||||||
|
|
||||||
|
// Clear UI state (can't use syncUIWithSession here because we also need to clear files)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
clearDiagram()
|
clearDiagram()
|
||||||
|
setDiagramHistory([])
|
||||||
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
handleFileChange([]) // Use handleFileChange to also clear pdfData
|
||||||
|
setUrlData(new Map())
|
||||||
const newSessionId = `session-${Date.now()}-${Math.random()
|
const newSessionId = `session-${Date.now()}-${Math.random()
|
||||||
.toString(36)
|
.toString(36)
|
||||||
.slice(2, 9)}`
|
.slice(2, 9)}`
|
||||||
setSessionId(newSessionId)
|
setSessionId(newSessionId)
|
||||||
xmlSnapshotsRef.current.clear()
|
xmlSnapshotsRef.current.clear()
|
||||||
// Clear localStorage with error handling
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_MESSAGES_KEY)
|
|
||||||
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
|
|
||||||
localStorage.removeItem(STORAGE_DIAGRAM_XML_KEY)
|
|
||||||
localStorage.setItem(STORAGE_SESSION_ID_KEY, newSessionId)
|
|
||||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||||
toast.success(dict.dialogs.clearSuccess)
|
toast.success(dict.dialogs.clearSuccess)
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to clear localStorage:", error)
|
|
||||||
toast.warning(dict.errors.storageUpdateFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowNewChatDialog(false)
|
// Clear URL param to show blank state
|
||||||
}, [clearDiagram, handleFileChange, setMessages, setSessionId])
|
router.replace(window.location.pathname, { scroll: false })
|
||||||
|
}, [
|
||||||
|
clearDiagram,
|
||||||
|
handleFileChange,
|
||||||
|
setMessages,
|
||||||
|
setSessionId,
|
||||||
|
sessionManager,
|
||||||
|
messages,
|
||||||
|
router,
|
||||||
|
dict.dialogs.clearSuccess,
|
||||||
|
buildSessionData,
|
||||||
|
setDiagramHistory,
|
||||||
|
])
|
||||||
|
|
||||||
const handleInputChange = (
|
const handleInputChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
@@ -701,7 +920,6 @@ export default function ChatPanel({
|
|||||||
xmlSnapshotsRef.current.delete(key)
|
xmlSnapshotsRef.current.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveXmlSnapshots()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send chat message with headers
|
// Send chat message with headers
|
||||||
@@ -762,6 +980,7 @@ export default function ChatPanel({
|
|||||||
files: File[],
|
files: File[],
|
||||||
pdfData: Map<File, FileData>,
|
pdfData: Map<File, FileData>,
|
||||||
imageParts?: any[],
|
imageParts?: any[],
|
||||||
|
urlDataParam?: Map<string, UrlData>,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
let userText = baseText
|
let userText = baseText
|
||||||
|
|
||||||
@@ -792,6 +1011,14 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlDataParam) {
|
||||||
|
for (const [url, data] of urlDataParam) {
|
||||||
|
if (data.content) {
|
||||||
|
userText += `\n\n[URL: ${url}]\nTitle: ${data.title}\n\n${data.content}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return userText
|
return userText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,12 +1142,16 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
|
||||||
|
shouldAnimatePanel && "animate-slide-in-right",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Toaster
|
<Toaster
|
||||||
position="bottom-center"
|
position="bottom-left"
|
||||||
richColors
|
richColors
|
||||||
expand
|
expand
|
||||||
style={{ position: "absolute" }}
|
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
maxWidth: "480px",
|
maxWidth: "480px",
|
||||||
@@ -933,7 +1164,15 @@ export default function ChatPanel({
|
|||||||
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
disabled={
|
||||||
|
status === "streaming" || status === "submitted"
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 overflow-x-hidden hover:opacity-80 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={dict.nav.newChat}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image
|
<Image
|
||||||
src={
|
src={
|
||||||
@@ -952,14 +1191,18 @@ export default function ChatPanel({
|
|||||||
Next AI Drawio
|
Next AI Drawio
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center gap-1 justify-end overflow-visible">
|
<div className="flex items-center gap-1 justify-end overflow-visible">
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent={dict.nav.newChat}
|
tooltipContent={dict.nav.newChat}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowNewChatDialog(true)}
|
onClick={handleNewChat}
|
||||||
className="hover:bg-accent"
|
disabled={
|
||||||
|
status === "streaming" || status === "submitted"
|
||||||
|
}
|
||||||
|
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
data-testid="new-chat-button"
|
||||||
>
|
>
|
||||||
<MessageSquarePlus
|
<MessageSquarePlus
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||||
@@ -972,6 +1215,7 @@ export default function ChatPanel({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowSettingsDialog(true)}
|
onClick={() => setShowSettingsDialog(true)}
|
||||||
className="hover:bg-accent"
|
className="hover:bg-accent"
|
||||||
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||||
@@ -1006,6 +1250,11 @@ export default function ChatPanel({
|
|||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
status={status}
|
status={status}
|
||||||
onEditMessage={handleEditMessage}
|
onEditMessage={handleEditMessage}
|
||||||
|
isRestored={isRestored}
|
||||||
|
sessions={sessionManager.sessions}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
loadedMessageIdsRef={loadedMessageIdsRef}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1029,10 +1278,11 @@ export default function ChatPanel({
|
|||||||
status={status}
|
status={status}
|
||||||
onSubmit={onFormSubmit}
|
onSubmit={onFormSubmit}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onClearChat={handleNewChat}
|
|
||||||
files={files}
|
files={files}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
pdfData={pdfData}
|
pdfData={pdfData}
|
||||||
|
urlData={urlData}
|
||||||
|
onUrlChange={setUrlData}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
error={error}
|
error={error}
|
||||||
models={modelConfig.models}
|
models={modelConfig.models}
|
||||||
@@ -1060,12 +1310,6 @@ export default function ChatPanel({
|
|||||||
onOpenChange={setShowModelConfigDialog}
|
onOpenChange={setShowModelConfigDialog}
|
||||||
modelConfig={modelConfig}
|
modelConfig={modelConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResetWarningModal
|
|
||||||
open={showNewChatDialog}
|
|
||||||
onOpenChange={setShowNewChatDialog}
|
|
||||||
onClear={handleNewChat}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
274
components/chat/ChatLobby.tsx
Normal file
274
components/chat/ChatLobby.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
MessageSquare,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useState } from "react"
|
||||||
|
import ExamplePanel from "@/components/chat-example-panel"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
|
||||||
|
interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
updatedAt: number
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatLobbyProps {
|
||||||
|
sessions: SessionMetadata[]
|
||||||
|
onSelectSession: (id: string) => void
|
||||||
|
onDeleteSession?: (id: string) => void
|
||||||
|
setInput: (input: string) => void
|
||||||
|
setFiles: (files: File[]) => void
|
||||||
|
dict: {
|
||||||
|
sessionHistory?: {
|
||||||
|
recentChats?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
noResults?: string
|
||||||
|
justNow?: string
|
||||||
|
deleteTitle?: string
|
||||||
|
deleteDescription?: string
|
||||||
|
}
|
||||||
|
examples?: {
|
||||||
|
quickExamples?: string
|
||||||
|
}
|
||||||
|
common: {
|
||||||
|
delete: string
|
||||||
|
cancel: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format session date
|
||||||
|
function formatSessionDate(
|
||||||
|
timestamp: number,
|
||||||
|
dict?: { justNow?: string },
|
||||||
|
): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
|
||||||
|
if (diffMins < 1) return dict?.justNow || "Just now"
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatLobby({
|
||||||
|
sessions,
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
setInput,
|
||||||
|
setFiles,
|
||||||
|
dict,
|
||||||
|
}: ChatLobbyProps) {
|
||||||
|
// Track whether examples section is expanded (collapsed by default when there's history)
|
||||||
|
const [examplesExpanded, setExamplesExpanded] = useState(false)
|
||||||
|
// Delete confirmation dialog state
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
|
||||||
|
// Search filter for history
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
|
const hasHistory = sessions.length > 0
|
||||||
|
|
||||||
|
if (!hasHistory) {
|
||||||
|
// Show full examples when no history
|
||||||
|
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show history + collapsible examples when there are sessions
|
||||||
|
return (
|
||||||
|
<div className="py-6 px-2 animate-fade-in">
|
||||||
|
{/* Recent Chats Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
|
||||||
|
{dict.sessionHistory?.recentChats || "Recent Chats"}
|
||||||
|
</p>
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
dict.sessionHistory?.searchPlaceholder ||
|
||||||
|
"Search chats..."
|
||||||
|
}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions
|
||||||
|
.filter((session) =>
|
||||||
|
session.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((session) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelectSession(session.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.thumbnailDataUrl ? (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={session.thumbnailDataUrl}
|
||||||
|
alt=""
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSessionDate(
|
||||||
|
session.updatedAt,
|
||||||
|
dict.sessionHistory,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDeleteSession && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSessionToDelete(session.id)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
title={dict.common.delete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sessions.filter((s) =>
|
||||||
|
s.title
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
).length === 0 &&
|
||||||
|
searchQuery && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{dict.sessionHistory?.noResults ||
|
||||||
|
"No chats found"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Examples Section */}
|
||||||
|
<div className="border-t border-border/50 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExamplesExpanded(!examplesExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dict.examples?.quickExamples || "Quick Examples"}
|
||||||
|
</span>
|
||||||
|
{examplesExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{examplesExpanded && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ExamplePanel
|
||||||
|
setInput={setInput}
|
||||||
|
setFiles={setFiles}
|
||||||
|
minimal
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-sm">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{dict.sessionHistory?.deleteTitle ||
|
||||||
|
"Delete this chat?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{dict.sessionHistory?.deleteDescription ||
|
||||||
|
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{dict.common.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
if (sessionToDelete && onDeleteSession) {
|
||||||
|
onDeleteSession(sessionToDelete)
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setSessionToDelete(null)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
250
components/chat/ToolCallCard.tsx
Normal file
250
components/chat/ToolCallCard.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||||
|
import type { Dispatch, SetStateAction } from "react"
|
||||||
|
import { CodeBlock } from "@/components/code-block"
|
||||||
|
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||||
|
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||||
|
|
||||||
|
interface ToolCallCardProps {
|
||||||
|
part: ToolPartLike
|
||||||
|
expandedTools: Record<string, boolean>
|
||||||
|
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||||
|
onCopy: (callId: string, text: string, isToolCall: boolean) => void
|
||||||
|
copiedToolCallId: string | null
|
||||||
|
copyFailedToolCallId: string | null
|
||||||
|
dict: {
|
||||||
|
tools: { complete: string }
|
||||||
|
chat: { copied: string; failedToCopy: string; copyResponse: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{operations.map((op, index) => (
|
||||||
|
<div
|
||||||
|
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||||
|
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||||
|
op.operation === "delete"
|
||||||
|
? "text-red-600"
|
||||||
|
: op.operation === "add"
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{op.operation}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
cell_id: {op.cell_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{op.new_xml && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||||
|
{op.new_xml}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallCard({
|
||||||
|
part,
|
||||||
|
expandedTools,
|
||||||
|
setExpandedTools,
|
||||||
|
onCopy,
|
||||||
|
copiedToolCallId,
|
||||||
|
copyFailedToolCallId,
|
||||||
|
dict,
|
||||||
|
}: ToolCallCardProps) {
|
||||||
|
const callId = part.toolCallId
|
||||||
|
const { state, input, output } = part
|
||||||
|
// Default to collapsed if tool is complete, expanded if still streaming
|
||||||
|
const isExpanded = expandedTools[callId] ?? state !== "output-available"
|
||||||
|
const toolName = part.type?.replace("tool-", "")
|
||||||
|
const isCopied = copiedToolCallId === callId
|
||||||
|
|
||||||
|
const toggleExpanded = () => {
|
||||||
|
setExpandedTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[callId]: !isExpanded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolDisplayName = (name: string) => {
|
||||||
|
switch (name) {
|
||||||
|
case "display_diagram":
|
||||||
|
return "Generate Diagram"
|
||||||
|
case "edit_diagram":
|
||||||
|
return "Edit Diagram"
|
||||||
|
case "get_shape_library":
|
||||||
|
return "Get Shape Library"
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
let textToCopy = ""
|
||||||
|
|
||||||
|
if (input && typeof input === "object") {
|
||||||
|
if (input.xml) {
|
||||||
|
textToCopy = input.xml
|
||||||
|
} else if (input.operations && Array.isArray(input.operations)) {
|
||||||
|
textToCopy = JSON.stringify(input.operations, null, 2)
|
||||||
|
} else if (Object.keys(input).length > 0) {
|
||||||
|
textToCopy = JSON.stringify(input, null, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
typeof output === "string"
|
||||||
|
) {
|
||||||
|
textToCopy = output
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textToCopy) {
|
||||||
|
onCopy(callId, textToCopy, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground/80">
|
||||||
|
{getToolDisplayName(toolName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{state === "input-streaming" && (
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{state === "output-available" && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
|
||||||
|
{dict.tools.complete}
|
||||||
|
</span>
|
||||||
|
{isExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
title={
|
||||||
|
copiedToolCallId === callId
|
||||||
|
? dict.chat.copied
|
||||||
|
: copyFailedToolCallId === callId
|
||||||
|
? dict.chat.failedToCopy
|
||||||
|
: dict.chat.copyResponse
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="w-4 h-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
// Check if this is a truncation (incomplete XML) vs real error
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return isTruncated ? (
|
||||||
|
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||||
|
Truncated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{input && Object.keys(input).length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{input && isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
|
||||||
|
{typeof input === "object" && input.xml ? (
|
||||||
|
<CodeBlock code={input.xml} language="xml" />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
input.operations &&
|
||||||
|
Array.isArray(input.operations) ? (
|
||||||
|
<OperationsDisplay operations={input.operations} />
|
||||||
|
) : typeof input === "object" &&
|
||||||
|
Object.keys(input).length > 0 ? (
|
||||||
|
<CodeBlock
|
||||||
|
code={JSON.stringify(input, null, 2)}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{output &&
|
||||||
|
state === "output-error" &&
|
||||||
|
(() => {
|
||||||
|
const isTruncated =
|
||||||
|
(toolName === "display_diagram" ||
|
||||||
|
toolName === "append_diagram") &&
|
||||||
|
!isMxCellXmlComplete(input?.xml)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
|
{isTruncated
|
||||||
|
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||||
|
: output}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Show get_shape_library output on success */}
|
||||||
|
{output &&
|
||||||
|
toolName === "get_shape_library" &&
|
||||||
|
state === "output-available" &&
|
||||||
|
isExpanded && (
|
||||||
|
<div className="px-4 py-3 border-t border-border/40">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">
|
||||||
|
Library loaded (
|
||||||
|
{typeof output === "string" ? output.length : 0}{" "}
|
||||||
|
chars)
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||||
|
{typeof output === "string"
|
||||||
|
? output.substring(0, 800) +
|
||||||
|
(output.length > 800 ? "\n..." : "")
|
||||||
|
: String(output)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
components/chat/types.ts
Normal file
16
components/chat/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface DiagramOperation {
|
||||||
|
operation: "update" | "add" | "delete"
|
||||||
|
cell_id: string
|
||||||
|
new_xml?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolPartLike {
|
||||||
|
type: string
|
||||||
|
toolCallId: string
|
||||||
|
state?: string
|
||||||
|
input?: {
|
||||||
|
xml?: string
|
||||||
|
operations?: DiagramOperation[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { FileCode, FileText, Loader2, X } from "lucide-react"
|
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDictionary } from "@/hooks/use-dictionary"
|
import { useDictionary } from "@/hooks/use-dictionary"
|
||||||
@@ -20,12 +20,19 @@ interface FilePreviewListProps {
|
|||||||
File,
|
File,
|
||||||
{ text: string; charCount: number; isExtracting: boolean }
|
{ text: string; charCount: number; isExtracting: boolean }
|
||||||
>
|
>
|
||||||
|
urlData?: Map<
|
||||||
|
string,
|
||||||
|
{ url: string; title: string; charCount: number; isExtracting: boolean }
|
||||||
|
>
|
||||||
|
onRemoveUrl?: (url: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreviewList({
|
export function FilePreviewList({
|
||||||
files,
|
files,
|
||||||
onRemoveFile,
|
onRemoveFile,
|
||||||
pdfData = new Map(),
|
pdfData = new Map(),
|
||||||
|
urlData,
|
||||||
|
onRemoveUrl,
|
||||||
}: FilePreviewListProps) {
|
}: FilePreviewListProps) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
@@ -77,7 +84,7 @@ export function FilePreviewList({
|
|||||||
}
|
}
|
||||||
}, [imageUrls, selectedImage])
|
}, [imageUrls, selectedImage])
|
||||||
|
|
||||||
if (files.length === 0) return null
|
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -152,6 +159,59 @@ export function FilePreviewList({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{/* URL previews */}
|
||||||
|
{urlData && urlData.size > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.from(urlData.entries()).map(
|
||||||
|
([url, data], index) => (
|
||||||
|
<div
|
||||||
|
key={url + index}
|
||||||
|
className="relative group"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-20 border rounded-md overflow-hidden bg-muted">
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-1">
|
||||||
|
{data.isExtracting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{dict.file.reading}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="h-6 w-6 text-blue-500 mb-1" />
|
||||||
|
<span className="text-xs text-center truncate w-full px-1">
|
||||||
|
{data.title.length > 10
|
||||||
|
? `${data.title.slice(0, 7)}...`
|
||||||
|
: data.title}
|
||||||
|
</span>
|
||||||
|
{data.charCount && (
|
||||||
|
<span className="text-[10px] text-green-600 font-medium">
|
||||||
|
{formatCharCount(
|
||||||
|
data.charCount,
|
||||||
|
)}{" "}
|
||||||
|
{dict.file.chars}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onRemoveUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveUrl(url)}
|
||||||
|
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
aria-label={dict.file.removeFile}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Image Modal/Lightbox */}
|
{/* Image Modal/Lightbox */}
|
||||||
{selectedImage && (
|
{selectedImage && (
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
gateway: "vercel",
|
gateway: "vercel",
|
||||||
edgeone: "tencent-cloud",
|
edgeone: "tencent-cloud",
|
||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
|
modelscope: "modelscope",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider logo component
|
// Provider logo component
|
||||||
@@ -102,6 +103,7 @@ function ProviderLogo({
|
|||||||
|
|
||||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
||||||
<img
|
<img
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
className={cn("size-4 dark:invert", className)}
|
className={cn("size-4 dark:invert", className)}
|
||||||
@@ -273,7 +275,7 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
// Validate all models
|
// Validate all models
|
||||||
const handleValidate = useCallback(async () => {
|
const handleValidate = useCallback(async () => {
|
||||||
if (!selectedProvider) return
|
if (!selectedProvider || !selectedProviderId) return
|
||||||
|
|
||||||
// Check credentials based on provider type
|
// Check credentials based on provider type
|
||||||
const isBedrock = selectedProvider.provider === "bedrock"
|
const isBedrock = selectedProvider.provider === "bedrock"
|
||||||
@@ -331,14 +333,14 @@ export function ModelConfigDialog({
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
updateModel(selectedProviderId!, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
validated: true,
|
validated: true,
|
||||||
validationError: undefined,
|
validationError: undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
allValid = false
|
allValid = false
|
||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId!, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
validated: false,
|
validated: false,
|
||||||
validationError: data.error || "Validation failed",
|
validationError: data.error || "Validation failed",
|
||||||
})
|
})
|
||||||
@@ -346,7 +348,7 @@ export function ModelConfigDialog({
|
|||||||
} catch {
|
} catch {
|
||||||
allValid = false
|
allValid = false
|
||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId!, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
validated: false,
|
validated: false,
|
||||||
validationError: "Network error",
|
validationError: "Network error",
|
||||||
})
|
})
|
||||||
@@ -357,7 +359,7 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
if (allValid) {
|
if (allValid) {
|
||||||
setValidationStatus("success")
|
setValidationStatus("success")
|
||||||
updateProvider(selectedProviderId!, { validated: true })
|
updateProvider(selectedProviderId, { validated: true })
|
||||||
// Reset to idle after showing success briefly (with cleanup)
|
// Reset to idle after showing success briefly (with cleanup)
|
||||||
if (validationResetTimeoutRef.current) {
|
if (validationResetTimeoutRef.current) {
|
||||||
clearTimeout(validationResetTimeoutRef.current)
|
clearTimeout(validationResetTimeoutRef.current)
|
||||||
@@ -1298,8 +1300,11 @@ export function ModelConfigDialog({
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
selectedProviderId
|
||||||
|
) {
|
||||||
updateModel(
|
updateModel(
|
||||||
selectedProviderId!,
|
selectedProviderId,
|
||||||
model.id,
|
model.id,
|
||||||
{
|
{
|
||||||
modelId:
|
modelId:
|
||||||
@@ -1312,6 +1317,7 @@ export function ModelConfigDialog({
|
|||||||
undefined,
|
undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(
|
onKeyDown={(
|
||||||
e,
|
e,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
|
|||||||
gateway: "vercel",
|
gateway: "vercel",
|
||||||
edgeone: "tencent-cloud",
|
edgeone: "tencent-cloud",
|
||||||
doubao: "bytedance",
|
doubao: "bytedance",
|
||||||
|
modelscope: "modelscope",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group models by providerLabel (handles duplicate providers)
|
// Group models by providerLabel (handles duplicate providers)
|
||||||
|
|||||||
116
components/url-input-dialog.tsx
Normal file
116
components/url-input-dialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Link, Loader2 } from "lucide-react"
|
||||||
|
import { 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 { useDictionary } from "@/hooks/use-dictionary"
|
||||||
|
|
||||||
|
interface UrlInputDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (url: string) => void
|
||||||
|
isExtracting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UrlInputDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isExtracting,
|
||||||
|
}: UrlInputDialogProps) {
|
||||||
|
const dict = useDictionary()
|
||||||
|
const [url, setUrl] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError(dict.url.enterUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url)
|
||||||
|
} catch {
|
||||||
|
setError(dict.url.invalidFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(url.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" && !isExtracting) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dict.url.title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dict.url.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value)
|
||||||
|
setError("")
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
disabled={isExtracting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isExtracting}
|
||||||
|
>
|
||||||
|
{dict.url.Cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isExtracting || !url.trim()}
|
||||||
|
>
|
||||||
|
{isExtracting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{dict.url.Extracting}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="mr-2 h-4 w-4" />
|
||||||
|
{dict.url.extract}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,15 +3,20 @@
|
|||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||||
import type { DrawIoEmbedRef } from "react-drawio"
|
import type { DrawIoEmbedRef } from "react-drawio"
|
||||||
import { STORAGE_DIAGRAM_XML_KEY } from "@/components/chat-panel"
|
import { toast } from "sonner"
|
||||||
import type { ExportFormat } from "@/components/save-dialog"
|
import type { ExportFormat } from "@/components/save-dialog"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { extractDiagramXML, validateAndFixXml } from "../lib/utils"
|
import {
|
||||||
|
extractDiagramXML,
|
||||||
|
isRealDiagram,
|
||||||
|
validateAndFixXml,
|
||||||
|
} from "../lib/utils"
|
||||||
|
|
||||||
interface DiagramContextType {
|
interface DiagramContextType {
|
||||||
chartXML: string
|
chartXML: string
|
||||||
latestSvg: string
|
latestSvg: string
|
||||||
diagramHistory: { svg: string; xml: string }[]
|
diagramHistory: { svg: string; xml: string }[]
|
||||||
|
setDiagramHistory: (history: { svg: string; xml: string }[]) => void
|
||||||
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
|
||||||
handleExport: () => void
|
handleExport: () => void
|
||||||
handleExportWithoutHistory: () => void
|
handleExportWithoutHistory: () => void
|
||||||
@@ -23,8 +28,9 @@ interface DiagramContextType {
|
|||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
|
successMessage?: string,
|
||||||
) => void
|
) => void
|
||||||
saveDiagramToStorage: () => Promise<void>
|
getThumbnailSvg: () => Promise<string | null>
|
||||||
isDrawioReady: boolean
|
isDrawioReady: boolean
|
||||||
onDrawioLoad: () => void
|
onDrawioLoad: () => void
|
||||||
resetDrawioReady: () => void
|
resetDrawioReady: () => void
|
||||||
@@ -41,72 +47,52 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{ svg: string; xml: string }[]
|
{ svg: string; xml: string }[]
|
||||||
>([])
|
>([])
|
||||||
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
||||||
const [canSaveDiagram, setCanSaveDiagram] = useState(false)
|
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
const hasCalledOnLoadRef = useRef(false)
|
const hasCalledOnLoadRef = useRef(false)
|
||||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
const resolverRef = 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 from localStorage
|
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
|
||||||
const hasDiagramRestoredRef = useRef<boolean>(false)
|
const hasDiagramRestoredRef = useRef<boolean>(false)
|
||||||
|
// Track latest chartXML for restoration after remount
|
||||||
|
const chartXMLRef = useRef<string>("")
|
||||||
|
|
||||||
const onDrawioLoad = () => {
|
const onDrawioLoad = () => {
|
||||||
// Only set ready state once to prevent infinite loops
|
// Only set ready state once to prevent infinite loops
|
||||||
if (hasCalledOnLoadRef.current) return
|
if (hasCalledOnLoadRef.current) return
|
||||||
hasCalledOnLoadRef.current = true
|
hasCalledOnLoadRef.current = true
|
||||||
// console.log("[DiagramContext] DrawIO loaded, setting ready state")
|
|
||||||
setIsDrawioReady(true)
|
setIsDrawioReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDrawioReady = () => {
|
const resetDrawioReady = () => {
|
||||||
// console.log("[DiagramContext] Resetting DrawIO ready state")
|
|
||||||
hasCalledOnLoadRef.current = false
|
hasCalledOnLoadRef.current = false
|
||||||
setIsDrawioReady(false)
|
setIsDrawioReady(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore diagram XML when DrawIO becomes ready
|
// Keep chartXMLRef in sync with state for restoration after remount
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- loadDiagram uses refs internally and is stable
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset restore flag when DrawIO is not ready (e.g., theme/UI change remounts it)
|
chartXMLRef.current = 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) {
|
if (!isDrawioReady) {
|
||||||
hasDiagramRestoredRef.current = false
|
hasDiagramRestoredRef.current = false
|
||||||
setCanSaveDiagram(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only restore once per ready cycle
|
||||||
if (hasDiagramRestoredRef.current) return
|
if (hasDiagramRestoredRef.current) return
|
||||||
hasDiagramRestoredRef.current = true
|
hasDiagramRestoredRef.current = true
|
||||||
|
|
||||||
try {
|
// Restore diagram from ref if we have one
|
||||||
const savedDiagramXml = localStorage.getItem(
|
const xmlToRestore = chartXMLRef.current
|
||||||
STORAGE_DIAGRAM_XML_KEY,
|
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
|
||||||
)
|
drawioRef.current.load({ xml: xmlToRestore })
|
||||||
if (savedDiagramXml) {
|
|
||||||
// Skip validation for trusted saved diagrams
|
|
||||||
loadDiagram(savedDiagramXml, true)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to restore diagram from localStorage:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow saving after restore is complete
|
|
||||||
setTimeout(() => {
|
|
||||||
setCanSaveDiagram(true)
|
|
||||||
}, 500)
|
|
||||||
}, [isDrawioReady])
|
}, [isDrawioReady])
|
||||||
|
|
||||||
// Save diagram XML to localStorage whenever it changes (debounced)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canSaveDiagram) return
|
|
||||||
if (!chartXML || chartXML.length <= 300) return
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, chartXML)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId)
|
|
||||||
}, [chartXML, canSaveDiagram])
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -132,27 +118,32 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current diagram to localStorage (used before theme/UI changes)
|
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||||
const saveDiagramToStorage = async (): Promise<void> => {
|
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||||
if (!drawioRef.current) return
|
if (!drawioRef.current) return null
|
||||||
|
// Don't export if diagram is empty
|
||||||
|
if (!isRealDiagram(chartXML)) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentXml = await Promise.race([
|
const svgData = await Promise.race([
|
||||||
new Promise<string>((resolve) => {
|
new Promise<string>((resolve) => {
|
||||||
resolverRef.current = resolve
|
resolverRef.current = resolve
|
||||||
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
|
||||||
}),
|
}),
|
||||||
new Promise<string>((_, reject) =>
|
new Promise<string>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error("Export timeout")), 2000),
|
setTimeout(() => reject(new Error("Export timeout")), 3000),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Only save if diagram has meaningful content (not empty template)
|
// Update latestSvg so it's available for future saves
|
||||||
if (currentXml && currentXml.length > 300) {
|
if (svgData?.includes("<svg")) {
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, currentXml)
|
setLatestSvg(svgData)
|
||||||
|
return svgData
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return null
|
||||||
console.error("Failed to save diagram to storage:", error)
|
} catch {
|
||||||
|
// Timeout is expected occasionally - don't log as error
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +238,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
filename: string,
|
filename: string,
|
||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
|
successMessage?: string,
|
||||||
) => {
|
) => {
|
||||||
if (!drawioRef.current) {
|
if (!drawioRef.current) {
|
||||||
console.warn("Draw.io editor not ready")
|
console.warn("Draw.io editor not ready")
|
||||||
@@ -273,9 +265,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
fileContent = xmlContent
|
fileContent = xmlContent
|
||||||
mimeType = "application/xml"
|
mimeType = "application/xml"
|
||||||
extension = ".drawio"
|
extension = ".drawio"
|
||||||
|
|
||||||
// Save to localStorage when user manually saves
|
|
||||||
localStorage.setItem(STORAGE_DIAGRAM_XML_KEY, xmlContent)
|
|
||||||
} else if (format === "png") {
|
} else if (format === "png") {
|
||||||
// PNG data comes as base64 data URL
|
// PNG data comes as base64 data URL
|
||||||
fileContent = exportData
|
fileContent = exportData
|
||||||
@@ -311,6 +300,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
// Show success toast after download is initiated
|
||||||
|
if (successMessage) {
|
||||||
|
toast.success(successMessage, {
|
||||||
|
position: "bottom-left",
|
||||||
|
duration: 2500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Delay URL revocation to ensure download completes
|
// Delay URL revocation to ensure download completes
|
||||||
if (!url.startsWith("data:")) {
|
if (!url.startsWith("data:")) {
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||||
@@ -346,6 +343,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
chartXML,
|
chartXML,
|
||||||
latestSvg,
|
latestSvg,
|
||||||
diagramHistory,
|
diagramHistory,
|
||||||
|
setDiagramHistory,
|
||||||
loadDiagram,
|
loadDiagram,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleExportWithoutHistory,
|
handleExportWithoutHistory,
|
||||||
@@ -354,7 +352,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
handleDiagramExport,
|
handleDiagramExport,
|
||||||
clearDiagram,
|
clearDiagram,
|
||||||
saveDiagramToFile,
|
saveDiagramToFile,
|
||||||
saveDiagramToStorage,
|
getThumbnailSvg,
|
||||||
isDrawioReady,
|
isDrawioReady,
|
||||||
onDrawioLoad,
|
onDrawioLoad,
|
||||||
resetDrawioReady,
|
resetDrawioReady,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
# environment:
|
||||||
# For subdirectory deployment, uncomment and set your path:
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
depends_on: [drawio]
|
depends_on: [drawio]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [安装](#安装)
|
- [安装](#安装)
|
||||||
- [部署](#部署)
|
- [部署](#部署)
|
||||||
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
|
||||||
- [部署到Vercel(推荐)](#部署到vercel推荐)
|
- [部署到Vercel](#部署到vercel)
|
||||||
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
|
||||||
- [多提供商支持](#多提供商支持)
|
- [多提供商支持](#多提供商支持)
|
||||||
- [工作原理](#工作原理)
|
- [工作原理](#工作原理)
|
||||||
@@ -179,7 +179,7 @@ npm run dev
|
|||||||
|
|
||||||
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
|
||||||
|
|
||||||
### 部署到Vercel(推荐)
|
### 部署到Vercel
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -204,6 +204,7 @@ npm run dev
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ AI_PROVIDER=ollama
|
|||||||
AI_MODEL=llama3.2
|
AI_MODEL=llama3.2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ModelScope
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_API_KEY=your_api_key
|
||||||
|
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||||
|
```
|
||||||
|
|
||||||
|
可选的自定义端点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
可选的自定义 URL:
|
可选的自定义 URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -158,6 +158,19 @@ Optional custom URL:
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ModelScope
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_API_KEY=your_api_key
|
||||||
|
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional custom endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
|
|
||||||
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
Vercel AI Gateway provides unified access to multiple AI providers through a single API key. This simplifies authentication and allows you to switch between providers without managing multiple API keys.
|
||||||
@@ -201,7 +214,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
|
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
|
||||||
```
|
```
|
||||||
|
|
||||||
## Model Capability Requirements
|
## Model Capability Requirements
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- [インストール](#インストール)
|
- [インストール](#インストール)
|
||||||
- [デプロイ](#デプロイ)
|
- [デプロイ](#デプロイ)
|
||||||
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
|
||||||
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
|
- [Vercelへのデプロイ](#vercelへのデプロイ)
|
||||||
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
|
||||||
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
|
||||||
- [仕組み](#仕組み)
|
- [仕組み](#仕組み)
|
||||||
@@ -180,7 +180,7 @@ npm run dev
|
|||||||
|
|
||||||
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
|
||||||
|
|
||||||
### Vercelへのデプロイ(推奨)
|
### Vercelへのデプロイ
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
|
||||||
|
|
||||||
@@ -205,6 +205,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
- SiliconFlow
|
- SiliconFlow
|
||||||
|
- ModelScope
|
||||||
- SGLang
|
- SGLang
|
||||||
- Vercel AI Gateway
|
- Vercel AI Gateway
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,19 @@ AI_MODEL=llama3.2
|
|||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ModelScope
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_API_KEY=your_api_key
|
||||||
|
AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507
|
||||||
|
```
|
||||||
|
|
||||||
|
任意のカスタムエンドポイント:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MODELSCOPE_BASE_URL=https://your-custom-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
### Vercel AI Gateway
|
### Vercel AI Gateway
|
||||||
|
|
||||||
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
Vercel AI Gateway は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function handleOptionsRequest(): Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onRequest({ request, env }: any) {
|
export async function onRequest({ request, env: _env }: any) {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return handleOptionsRequest()
|
return handleOptionsRequest()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
|||||||
apiKey: "SILICONFLOW_API_KEY",
|
apiKey: "SILICONFLOW_API_KEY",
|
||||||
baseUrl: "SILICONFLOW_BASE_URL",
|
baseUrl: "SILICONFLOW_BASE_URL",
|
||||||
},
|
},
|
||||||
|
modelscope: {
|
||||||
|
apiKey: "MODELSCOPE_API_KEY",
|
||||||
|
baseUrl: "MODELSCOPE_BASE_URL",
|
||||||
|
},
|
||||||
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
|
||||||
// bedrock and ollama don't use API keys in the same way
|
// bedrock and ollama don't use API keys in the same way
|
||||||
bedrock: { apiKey: "", baseUrl: "" },
|
bedrock: { apiKey: "", baseUrl: "" },
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<option value="openrouter">OpenRouter</option>
|
<option value="openrouter">OpenRouter</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="siliconflow">SiliconFlow</option>
|
<option value="siliconflow">SiliconFlow</option>
|
||||||
|
<option value="modelscope">ModelScope</option>
|
||||||
<option value="ollama">Ollama (Local)</option>
|
<option value="ollama">Ollama (Local)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ function getProviderLabel(provider) {
|
|||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
siliconflow: "SiliconFlow",
|
siliconflow: "SiliconFlow",
|
||||||
|
modelscope: "ModelScope",
|
||||||
ollama: "Ollama",
|
ollama: "Ollama",
|
||||||
}
|
}
|
||||||
return labels[provider] || provider
|
return labels[provider] || provider
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# SGLANG_API_KEY=your-sglang-api-key
|
# SGLANG_API_KEY=your-sglang-api-key
|
||||||
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
# SGLANG_BASE_URL=http://127.0.0.1:8000/v1 # Your SGLang endpoint
|
||||||
|
|
||||||
|
# ModelScope Configuration
|
||||||
|
# MODELSCOPE_API_KEY=ms-...
|
||||||
|
# MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# ByteDance Doubao Configuration (via Volcengine)
|
# ByteDance Doubao Configuration (via Volcengine)
|
||||||
# DOUBAO_API_KEY=your-doubao-api-key
|
# DOUBAO_API_KEY=your-doubao-api-key
|
||||||
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint
|
||||||
|
|||||||
322
hooks/use-session-manager.ts
Normal file
322
hooks/use-session-manager.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ChatSession,
|
||||||
|
createEmptySession,
|
||||||
|
deleteSession as deleteSessionFromDB,
|
||||||
|
enforceSessionLimit,
|
||||||
|
extractTitle,
|
||||||
|
getAllSessionMetadata,
|
||||||
|
getSession,
|
||||||
|
isIndexedDBAvailable,
|
||||||
|
migrateFromLocalStorage,
|
||||||
|
type SessionMetadata,
|
||||||
|
type StoredMessage,
|
||||||
|
saveSession,
|
||||||
|
} from "@/lib/session-storage"
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
messages: StoredMessage[]
|
||||||
|
xmlSnapshots: [number, string][]
|
||||||
|
diagramXml: string
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
diagramHistory?: { svg: string; xml: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSessionManagerReturn {
|
||||||
|
// State
|
||||||
|
sessions: SessionMetadata[]
|
||||||
|
currentSessionId: string | null
|
||||||
|
currentSession: ChatSession | null
|
||||||
|
isLoading: boolean
|
||||||
|
isAvailable: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
switchSession: (id: string) => Promise<SessionData | null>
|
||||||
|
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
|
||||||
|
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
|
||||||
|
saveCurrentSession: (
|
||||||
|
data: SessionData,
|
||||||
|
forSessionId?: string | null,
|
||||||
|
) => Promise<void>
|
||||||
|
refreshSessions: () => Promise<void>
|
||||||
|
clearCurrentSession: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSessionManagerOptions {
|
||||||
|
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
||||||
|
initialSessionId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionManager(
|
||||||
|
options: UseSessionManagerOptions = {},
|
||||||
|
): UseSessionManagerReturn {
|
||||||
|
const { initialSessionId } = options
|
||||||
|
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isAvailable, setIsAvailable] = useState(false)
|
||||||
|
|
||||||
|
const isInitializedRef = useRef(false)
|
||||||
|
// Sequence guard for URL changes - prevents out-of-order async resolution
|
||||||
|
const urlChangeSequenceRef = useRef(0)
|
||||||
|
|
||||||
|
// Load sessions list
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
const metadata = await getAllSessionMetadata()
|
||||||
|
setSessions(metadata)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh sessions:", error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitializedRef.current) return
|
||||||
|
isInitializedRef.current = true
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
if (!isIndexedDBAvailable()) {
|
||||||
|
setIsAvailable(false)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAvailable(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run migration first (one-time conversion from localStorage)
|
||||||
|
await migrateFromLocalStorage()
|
||||||
|
|
||||||
|
// Load sessions list
|
||||||
|
const metadata = await getAllSessionMetadata()
|
||||||
|
setSessions(metadata)
|
||||||
|
|
||||||
|
// Only load a session if initialSessionId is provided (from URL param)
|
||||||
|
if (initialSessionId) {
|
||||||
|
const session = await getSession(initialSessionId)
|
||||||
|
if (session) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
}
|
||||||
|
// If session not found, stay in blank state (URL has invalid session ID)
|
||||||
|
}
|
||||||
|
// If no initialSessionId, start with blank state (no auto-restore)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize session manager:", error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
}, [initialSessionId])
|
||||||
|
|
||||||
|
// Handle URL session ID changes after initialization
|
||||||
|
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
|
||||||
|
// when clearCurrentSession() is called before URL updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitializedRef.current) return // Wait for initial load
|
||||||
|
if (!isAvailable) return
|
||||||
|
|
||||||
|
// Increment sequence to invalidate any pending async operations
|
||||||
|
urlChangeSequenceRef.current++
|
||||||
|
const currentSequence = urlChangeSequenceRef.current
|
||||||
|
|
||||||
|
async function handleSessionIdChange() {
|
||||||
|
if (initialSessionId) {
|
||||||
|
// URL has session ID - load it
|
||||||
|
const session = await getSession(initialSessionId)
|
||||||
|
|
||||||
|
// Check if this request is still the latest (sequence guard)
|
||||||
|
// If not, a newer URL change happened while we were loading
|
||||||
|
if (currentSequence !== urlChangeSequenceRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// Only update if the session is different from current
|
||||||
|
setCurrentSessionId((current) => {
|
||||||
|
if (current !== session.id) {
|
||||||
|
setCurrentSession(session)
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Removed: else clause that clears session
|
||||||
|
// Clearing is now handled explicitly by clearCurrentSession()
|
||||||
|
// This prevents race conditions when URL update is async
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSessionIdChange()
|
||||||
|
}, [initialSessionId, isAvailable])
|
||||||
|
|
||||||
|
// Refresh sessions on window focus (multi-tab sync)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
refreshSessions()
|
||||||
|
}
|
||||||
|
window.addEventListener("focus", handleFocus)
|
||||||
|
return () => window.removeEventListener("focus", handleFocus)
|
||||||
|
}, [refreshSessions])
|
||||||
|
|
||||||
|
// Switch to a different session
|
||||||
|
const switchSession = useCallback(
|
||||||
|
async (id: string): Promise<SessionData | null> => {
|
||||||
|
if (id === currentSessionId) return null
|
||||||
|
|
||||||
|
// Save current session first if it has messages
|
||||||
|
if (currentSession && currentSession.messages.length > 0) {
|
||||||
|
await saveSession(currentSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the target session
|
||||||
|
const session = await getSession(id)
|
||||||
|
if (!session) {
|
||||||
|
console.error("Session not found:", id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setCurrentSession(session)
|
||||||
|
setCurrentSessionId(session.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: session.messages,
|
||||||
|
xmlSnapshots: session.xmlSnapshots,
|
||||||
|
diagramXml: session.diagramXml,
|
||||||
|
thumbnailDataUrl: session.thumbnailDataUrl,
|
||||||
|
diagramHistory: session.diagramHistory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentSessionId, currentSession],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete a session
|
||||||
|
const deleteSession = useCallback(
|
||||||
|
async (id: string): Promise<{ wasCurrentSession: boolean }> => {
|
||||||
|
const wasCurrentSession = id === currentSessionId
|
||||||
|
await deleteSessionFromDB(id)
|
||||||
|
|
||||||
|
// If deleting current session, clear state (caller will show new empty session)
|
||||||
|
if (wasCurrentSession) {
|
||||||
|
setCurrentSession(null)
|
||||||
|
setCurrentSessionId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSessions()
|
||||||
|
|
||||||
|
return { wasCurrentSession }
|
||||||
|
},
|
||||||
|
[currentSessionId, refreshSessions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save current session data (debounced externally by caller)
|
||||||
|
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
|
||||||
|
const saveCurrentSession = useCallback(
|
||||||
|
async (
|
||||||
|
data: SessionData,
|
||||||
|
forSessionId?: string | null,
|
||||||
|
): Promise<void> => {
|
||||||
|
// If forSessionId is provided, verify it matches current session
|
||||||
|
// This prevents stale debounced saves from overwriting a newly switched session
|
||||||
|
if (
|
||||||
|
forSessionId !== undefined &&
|
||||||
|
forSessionId !== currentSessionId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
// Create a new session if none exists
|
||||||
|
const newSession: ChatSession = {
|
||||||
|
...createEmptySession(),
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl: data.thumbnailDataUrl,
|
||||||
|
diagramHistory: data.diagramHistory,
|
||||||
|
title: extractTitle(data.messages),
|
||||||
|
}
|
||||||
|
await saveSession(newSession)
|
||||||
|
await enforceSessionLimit()
|
||||||
|
setCurrentSession(newSession)
|
||||||
|
setCurrentSessionId(newSession.id)
|
||||||
|
await refreshSessions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing session
|
||||||
|
const updatedSession: ChatSession = {
|
||||||
|
...currentSession,
|
||||||
|
messages: data.messages,
|
||||||
|
xmlSnapshots: data.xmlSnapshots,
|
||||||
|
diagramXml: data.diagramXml,
|
||||||
|
thumbnailDataUrl:
|
||||||
|
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
|
||||||
|
diagramHistory:
|
||||||
|
data.diagramHistory ?? currentSession.diagramHistory,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
// Update title if it's still default and we have messages
|
||||||
|
title:
|
||||||
|
currentSession.title === "New Chat" &&
|
||||||
|
data.messages.length > 0
|
||||||
|
? extractTitle(data.messages)
|
||||||
|
: currentSession.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSession(updatedSession)
|
||||||
|
setCurrentSession(updatedSession)
|
||||||
|
|
||||||
|
// Update sessions list metadata
|
||||||
|
setSessions((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === updatedSession.id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
title: updatedSession.title,
|
||||||
|
updatedAt: updatedSession.updatedAt,
|
||||||
|
messageCount: updatedSession.messages.length,
|
||||||
|
hasDiagram:
|
||||||
|
!!updatedSession.diagramXml &&
|
||||||
|
updatedSession.diagramXml.trim().length > 0,
|
||||||
|
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[currentSession, currentSessionId, refreshSessions],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear current session state (for starting fresh without loading another session)
|
||||||
|
const clearCurrentSession = useCallback(() => {
|
||||||
|
setCurrentSession(null)
|
||||||
|
setCurrentSessionId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
currentSession,
|
||||||
|
isLoading,
|
||||||
|
isAvailable,
|
||||||
|
switchSession,
|
||||||
|
deleteSession,
|
||||||
|
saveCurrentSession,
|
||||||
|
refreshSessions,
|
||||||
|
clearCurrentSession,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export type ProviderName =
|
|||||||
| "gateway"
|
| "gateway"
|
||||||
| "edgeone"
|
| "edgeone"
|
||||||
| "doubao"
|
| "doubao"
|
||||||
|
| "modelscope"
|
||||||
|
|
||||||
interface ModelConfig {
|
interface ModelConfig {
|
||||||
model: any
|
model: any
|
||||||
@@ -59,6 +60,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
|||||||
"gateway",
|
"gateway",
|
||||||
"edgeone",
|
"edgeone",
|
||||||
"doubao",
|
"doubao",
|
||||||
|
"modelscope",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bedrock provider options for Anthropic beta features
|
// Bedrock provider options for Anthropic beta features
|
||||||
@@ -353,6 +355,7 @@ function buildProviderOptions(
|
|||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "sglang":
|
case "sglang":
|
||||||
case "gateway":
|
case "gateway":
|
||||||
|
case "modelscope":
|
||||||
case "doubao": {
|
case "doubao": {
|
||||||
// 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
|
||||||
@@ -381,6 +384,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
|||||||
gateway: "AI_GATEWAY_API_KEY",
|
gateway: "AI_GATEWAY_API_KEY",
|
||||||
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",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,7 +449,7 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* Get the AI model based on environment variables
|
* Get the AI model based on environment variables
|
||||||
*
|
*
|
||||||
* Environment variables:
|
* Environment variables:
|
||||||
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway)
|
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope)
|
||||||
* - AI_MODEL: The model ID/name for the selected provider
|
* - AI_MODEL: The model ID/name for the selected provider
|
||||||
*
|
*
|
||||||
* Provider-specific env vars:
|
* Provider-specific env vars:
|
||||||
@@ -463,6 +467,8 @@ function validateProviderCredentials(provider: ProviderName): void {
|
|||||||
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
* - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.com/v1)
|
||||||
* - SGLANG_API_KEY: SGLang API key
|
* - SGLANG_API_KEY: SGLang API key
|
||||||
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
* - SGLANG_BASE_URL: SGLang endpoint (optional)
|
||||||
|
* - MODELSCOPE_API_KEY: ModelScope API key
|
||||||
|
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
|
||||||
*/
|
*/
|
||||||
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||||
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
|
||||||
@@ -537,6 +543,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
`- AZURE_API_KEY for Azure\n` +
|
`- AZURE_API_KEY for Azure\n` +
|
||||||
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
|
||||||
`- SGLANG_API_KEY for SGLang\n` +
|
`- SGLANG_API_KEY for SGLang\n` +
|
||||||
|
`- MODELSCOPE_API_KEY for ModelScope\n` +
|
||||||
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
`Or set AI_PROVIDER=ollama for local Ollama.`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -573,8 +580,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
const bedrockProvider = hasClientCredentials
|
const bedrockProvider = hasClientCredentials
|
||||||
? createAmazonBedrock({
|
? createAmazonBedrock({
|
||||||
region: bedrockRegion,
|
region: bedrockRegion,
|
||||||
accessKeyId: overrides.awsAccessKeyId!,
|
accessKeyId: overrides.awsAccessKeyId as string,
|
||||||
secretAccessKey: overrides.awsSecretAccessKey!,
|
secretAccessKey: overrides.awsSecretAccessKey as string,
|
||||||
...(overrides?.awsSessionToken && {
|
...(overrides?.awsSessionToken && {
|
||||||
sessionToken: overrides.awsSessionToken,
|
sessionToken: overrides.awsSessionToken,
|
||||||
}),
|
}),
|
||||||
@@ -871,17 +878,44 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
overrides?.baseUrl ||
|
overrides?.baseUrl ||
|
||||||
process.env.DOUBAO_BASE_URL ||
|
process.env.DOUBAO_BASE_URL ||
|
||||||
"https://ark.cn-beijing.volces.com/api/v3"
|
"https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
const lowerModelId = modelId.toLowerCase()
|
||||||
|
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
|
||||||
|
if (
|
||||||
|
lowerModelId.includes("deepseek") ||
|
||||||
|
lowerModelId.includes("kimi")
|
||||||
|
) {
|
||||||
const doubaoProvider = createDeepSeek({
|
const doubaoProvider = createDeepSeek({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
})
|
||||||
model = doubaoProvider(modelId)
|
model = doubaoProvider(modelId)
|
||||||
|
} else {
|
||||||
|
const doubaoProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = doubaoProvider.chat(modelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "modelscope": {
|
||||||
|
const apiKey = overrides?.apiKey || process.env.MODELSCOPE_API_KEY
|
||||||
|
const baseURL =
|
||||||
|
overrides?.baseUrl ||
|
||||||
|
process.env.MODELSCOPE_BASE_URL ||
|
||||||
|
"https://api-inference.modelscope.cn/v1"
|
||||||
|
const modelscopeProvider = createOpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL,
|
||||||
|
})
|
||||||
|
model = modelscopeProvider.chat(modelId)
|
||||||
break
|
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`,
|
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
lib/chat-helpers.ts
Normal file
89
lib/chat-helpers.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Shared helper functions for chat route
|
||||||
|
// Exported for testing
|
||||||
|
|
||||||
|
// File upload limits (must match client-side)
|
||||||
|
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
|
export const MAX_FILES = 5
|
||||||
|
|
||||||
|
// Helper function to validate file parts in messages
|
||||||
|
export function validateFileParts(messages: any[]): {
|
||||||
|
valid: boolean
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const fileParts =
|
||||||
|
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||||
|
|
||||||
|
if (fileParts.length > MAX_FILES) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePart of fileParts) {
|
||||||
|
// Data URLs format: data:image/png;base64,<data>
|
||||||
|
// Base64 increases size by ~33%, so we check the decoded size
|
||||||
|
if (filePart.url?.startsWith("data:")) {
|
||||||
|
const base64Data = filePart.url.split(",")[1]
|
||||||
|
if (base64Data) {
|
||||||
|
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||||
|
if (sizeInBytes > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if diagram is minimal/empty
|
||||||
|
export function isMinimalDiagram(xml: string): boolean {
|
||||||
|
const stripped = xml.replace(/\s/g, "")
|
||||||
|
return !stripped.includes('id="2"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to replace historical tool call XML with placeholders
|
||||||
|
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||||
|
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||||
|
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||||
|
return messages.map((msg) => {
|
||||||
|
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
const replacedContent = msg.content
|
||||||
|
.map((part: any) => {
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
const toolName = part.toolName
|
||||||
|
// Fix invalid/undefined inputs from interrupted streaming
|
||||||
|
if (
|
||||||
|
!part.input ||
|
||||||
|
typeof part.input !== "object" ||
|
||||||
|
Object.keys(part.input).length === 0
|
||||||
|
) {
|
||||||
|
// Skip tool calls with invalid inputs entirely
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
toolName === "display_diagram" ||
|
||||||
|
toolName === "edit_diagram"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
input: {
|
||||||
|
placeholder:
|
||||||
|
"[XML content replaced - see current diagram XML in system context]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||||
|
return { ...msg, content: replacedContent }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Describe your diagram or upload a file...",
|
"placeholder": "Describe your diagram or upload a file...",
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"badResponse": "Bad response",
|
"badResponse": "Bad response",
|
||||||
"clickToEdit": "Click to edit",
|
"clickToEdit": "Click to edit",
|
||||||
"editMessage": "Edit message",
|
"editMessage": "Edit message",
|
||||||
"saveAndSubmit": "Save & Submit"
|
"saveAndSubmit": "Save & Submit",
|
||||||
|
"ExtractURL": "Extract from URL"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "Create diagrams with AI",
|
"title": "Create diagrams with AI",
|
||||||
@@ -117,7 +119,8 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG Image",
|
"png": "PNG Image",
|
||||||
"svg": "SVG Image"
|
"svg": "SVG Image"
|
||||||
}
|
},
|
||||||
|
"savedSuccessfully": "Saved successfully!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "Diagram History",
|
"title": "Diagram History",
|
||||||
@@ -185,6 +188,15 @@
|
|||||||
"chars": "chars",
|
"chars": "chars",
|
||||||
"removeFile": "Remove file"
|
"removeFile": "Remove file"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "Extract Content from URL",
|
||||||
|
"description": "Paste a URL to extract and analyze its content",
|
||||||
|
"Extracting": "Extracting...",
|
||||||
|
"extract": "Extract",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"enterUrl": "Please enter a URL",
|
||||||
|
"invalidFormat": "Invalid URL format"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "Thinking...",
|
"thinking": "Thinking...",
|
||||||
"thoughtFor": "Thought for {duration} seconds",
|
"thoughtFor": "Thought for {duration} seconds",
|
||||||
@@ -212,6 +224,22 @@
|
|||||||
"contactMe": "Contact Me",
|
"contactMe": "Contact Me",
|
||||||
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "Chat History",
|
||||||
|
"newChat": "New Chat",
|
||||||
|
"empty": "No chat history yet",
|
||||||
|
"emptyHint": "Start a conversation to begin",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday",
|
||||||
|
"thisWeek": "This Week",
|
||||||
|
"earlier": "Earlier",
|
||||||
|
"deleteTitle": "Delete this chat?",
|
||||||
|
"deleteDescription": "This will permanently delete this chat session and its diagram. This action cannot be undone.",
|
||||||
|
"recentChats": "Recent Chats",
|
||||||
|
"justNow": "Just now",
|
||||||
|
"searchPlaceholder": "Search chats...",
|
||||||
|
"noResults": "No chats found"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI Model Configuration",
|
"title": "AI Model Configuration",
|
||||||
"description": "Configure multiple AI providers and models",
|
"description": "Configure multiple AI providers and models",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"badResponse": "悪い応答",
|
"badResponse": "悪い応答",
|
||||||
"clickToEdit": "クリックして編集",
|
"clickToEdit": "クリックして編集",
|
||||||
"editMessage": "メッセージを編集",
|
"editMessage": "メッセージを編集",
|
||||||
"saveAndSubmit": "保存して送信"
|
"saveAndSubmit": "保存して送信",
|
||||||
|
"ExtractURL": "URLから抽出"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "AI でダイアグラムを作成",
|
"title": "AI でダイアグラムを作成",
|
||||||
@@ -117,7 +119,8 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 画像",
|
"png": "PNG 画像",
|
||||||
"svg": "SVG 画像"
|
"svg": "SVG 画像"
|
||||||
}
|
},
|
||||||
|
"savedSuccessfully": "保存完了!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "ダイアグラム履歴",
|
"title": "ダイアグラム履歴",
|
||||||
@@ -185,6 +188,15 @@
|
|||||||
"chars": "文字",
|
"chars": "文字",
|
||||||
"removeFile": "ファイルを削除"
|
"removeFile": "ファイルを削除"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "URLからコンテンツを抽出",
|
||||||
|
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
|
||||||
|
"Extracting": "抽出中...",
|
||||||
|
"extract": "抽出",
|
||||||
|
"Cancel": "キャンセル",
|
||||||
|
"enterUrl": "URLを入力してください",
|
||||||
|
"invalidFormat": "無効なURL形式です"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "考え中...",
|
"thinking": "考え中...",
|
||||||
"thoughtFor": "{duration} 秒考えました",
|
"thoughtFor": "{duration} 秒考えました",
|
||||||
@@ -212,6 +224,22 @@
|
|||||||
"contactMe": "お問い合わせ",
|
"contactMe": "お問い合わせ",
|
||||||
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "チャット履歴",
|
||||||
|
"newChat": "新しいチャット",
|
||||||
|
"empty": "チャット履歴はまだありません",
|
||||||
|
"emptyHint": "会話を始めてください",
|
||||||
|
"today": "今日",
|
||||||
|
"yesterday": "昨日",
|
||||||
|
"thisWeek": "今週",
|
||||||
|
"earlier": "それ以前",
|
||||||
|
"deleteTitle": "このチャットを削除しますか?",
|
||||||
|
"deleteDescription": "このチャットセッションとダイアグラムは完全に削除されます。この操作は取り消せません。",
|
||||||
|
"recentChats": "最近のチャット",
|
||||||
|
"justNow": "たった今",
|
||||||
|
"searchPlaceholder": "チャットを検索...",
|
||||||
|
"noResults": "チャットが見つかりません"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AIモデル設定",
|
"title": "AIモデル設定",
|
||||||
"description": "複数のAIプロバイダーとモデルを設定",
|
"description": "複数のAIプロバイダーとモデルを設定",
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"azure": "Azure OpenAI",
|
"azure": "Azure OpenAI",
|
||||||
"openrouter": "OpenRouter",
|
"openrouter": "OpenRouter",
|
||||||
"deepseek": "DeepSeek",
|
"deepseek": "DeepSeek",
|
||||||
"siliconflow": "SiliconFlow"
|
"siliconflow": "SiliconFlow",
|
||||||
|
"modelscope": "ModelScope"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "描述您的图表或上传文件...",
|
"placeholder": "描述您的图表或上传文件...",
|
||||||
@@ -51,7 +52,8 @@
|
|||||||
"badResponse": "无帮助",
|
"badResponse": "无帮助",
|
||||||
"clickToEdit": "点击编辑",
|
"clickToEdit": "点击编辑",
|
||||||
"editMessage": "编辑消息",
|
"editMessage": "编辑消息",
|
||||||
"saveAndSubmit": "保存并提交"
|
"saveAndSubmit": "保存并提交",
|
||||||
|
"ExtractURL": "从 URL 提取"
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"title": "用 AI 创建图表",
|
"title": "用 AI 创建图表",
|
||||||
@@ -117,7 +119,8 @@
|
|||||||
"drawio": "Draw.io XML",
|
"drawio": "Draw.io XML",
|
||||||
"png": "PNG 图片",
|
"png": "PNG 图片",
|
||||||
"svg": "SVG 图片"
|
"svg": "SVG 图片"
|
||||||
}
|
},
|
||||||
|
"savedSuccessfully": "保存成功!"
|
||||||
},
|
},
|
||||||
"history": {
|
"history": {
|
||||||
"title": "图表历史",
|
"title": "图表历史",
|
||||||
@@ -185,6 +188,15 @@
|
|||||||
"chars": "字符",
|
"chars": "字符",
|
||||||
"removeFile": "移除文件"
|
"removeFile": "移除文件"
|
||||||
},
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "从 URL 提取内容",
|
||||||
|
"description": "粘贴 URL 以提取和分析其内容",
|
||||||
|
"Extracting": "提取中...",
|
||||||
|
"extract": "提取",
|
||||||
|
"Cancel": "取消",
|
||||||
|
"enterUrl": "请输入 URL",
|
||||||
|
"invalidFormat": "URL 格式无效"
|
||||||
|
},
|
||||||
"reasoning": {
|
"reasoning": {
|
||||||
"thinking": "思考中...",
|
"thinking": "思考中...",
|
||||||
"thoughtFor": "思考了 {duration} 秒",
|
"thoughtFor": "思考了 {duration} 秒",
|
||||||
@@ -212,6 +224,22 @@
|
|||||||
"contactMe": "联系我",
|
"contactMe": "联系我",
|
||||||
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
"usageNotice": "由于使用量过高,我已将模型从 Claude 更换为 minimax-m2,并设置了一些用量限制。详情请查看关于页面。"
|
||||||
},
|
},
|
||||||
|
"sessionHistory": {
|
||||||
|
"tooltip": "聊天历史",
|
||||||
|
"newChat": "新对话",
|
||||||
|
"empty": "暂无聊天记录",
|
||||||
|
"emptyHint": "开始对话吧",
|
||||||
|
"today": "今天",
|
||||||
|
"yesterday": "昨天",
|
||||||
|
"thisWeek": "本周",
|
||||||
|
"earlier": "更早",
|
||||||
|
"deleteTitle": "删除此对话?",
|
||||||
|
"deleteDescription": "这将永久删除此聊天会话及其图表。此操作无法撤消。",
|
||||||
|
"recentChats": "最近对话",
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"searchPlaceholder": "搜索对话...",
|
||||||
|
"noResults": "未找到对话"
|
||||||
|
},
|
||||||
"modelConfig": {
|
"modelConfig": {
|
||||||
"title": "AI 模型配置",
|
"title": "AI 模型配置",
|
||||||
"description": "配置多个 AI 提供商和模型",
|
"description": "配置多个 AI 提供商和模型",
|
||||||
|
|||||||
338
lib/session-storage.ts
Normal file
338
lib/session-storage.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const DB_NAME = "next-ai-drawio"
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = "sessions"
|
||||||
|
const MIGRATION_FLAG = "next-ai-drawio-migrated-to-idb"
|
||||||
|
const MAX_SESSIONS = 50
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messages: StoredMessage[]
|
||||||
|
xmlSnapshots: [number, string][]
|
||||||
|
diagramXml: string
|
||||||
|
thumbnailDataUrl?: string // Small PNG preview of the diagram
|
||||||
|
diagramHistory?: { svg: string; xml: string }[] // Version history of diagram edits
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredMessage {
|
||||||
|
id: string
|
||||||
|
role: "user" | "assistant" | "system"
|
||||||
|
parts: Array<{ type: string; [key: string]: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMetadata {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messageCount: number
|
||||||
|
hasDiagram: boolean
|
||||||
|
thumbnailDataUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSessionDB extends DBSchema {
|
||||||
|
sessions: {
|
||||||
|
key: string
|
||||||
|
value: ChatSession
|
||||||
|
indexes: { "by-updated": number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database singleton
|
||||||
|
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||||
|
|
||||||
|
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db, oldVersion) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "id",
|
||||||
|
})
|
||||||
|
store.createIndex("by-updated", "updatedAt")
|
||||||
|
}
|
||||||
|
// Future migrations: if (oldVersion < 2) { ... }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 getAllSessionMetadata(): Promise<SessionMetadata[]> {
|
||||||
|
if (!isIndexedDBAvailable()) return []
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, "readonly")
|
||||||
|
const index = tx.store.index("by-updated")
|
||||||
|
const metadata: SessionMetadata[] = []
|
||||||
|
|
||||||
|
// Use cursor to read only metadata fields (avoids loading full messages/XML)
|
||||||
|
let cursor = await index.openCursor(null, "prev") // newest first
|
||||||
|
while (cursor) {
|
||||||
|
const s = cursor.value
|
||||||
|
metadata.push({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
updatedAt: s.updatedAt,
|
||||||
|
messageCount: s.messages.length,
|
||||||
|
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
|
||||||
|
thumbnailDataUrl: s.thumbnailDataUrl,
|
||||||
|
})
|
||||||
|
cursor = await cursor.continue()
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session metadata:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(id: string): Promise<ChatSession | null> {
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return (await db.get(STORE_NAME, id)) || null
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||||
|
if (!isIndexedDBAvailable()) return false
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.put(STORE_NAME, session)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
// Handle quota exceeded
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === "QuotaExceededError"
|
||||||
|
) {
|
||||||
|
console.warn("Storage quota exceeded, deleting oldest session...")
|
||||||
|
await deleteOldestSession()
|
||||||
|
// Retry once
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.put(STORE_NAME, session)
|
||||||
|
return true
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error(
|
||||||
|
"Failed to save session after cleanup:",
|
||||||
|
retryError,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Failed to save session:", error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.delete(STORE_NAME, id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionCount(): Promise<number> {
|
||||||
|
if (!isIndexedDBAvailable()) return 0
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
return await db.count(STORE_NAME)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get session count:", error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOldestSession(): Promise<void> {
|
||||||
|
if (!isIndexedDBAvailable()) return
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, "readwrite")
|
||||||
|
const index = tx.store.index("by-updated")
|
||||||
|
const cursor = await index.openCursor()
|
||||||
|
if (cursor) {
|
||||||
|
await cursor.delete()
|
||||||
|
}
|
||||||
|
await tx.done
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete oldest session:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max sessions limit
|
||||||
|
export async function enforceSessionLimit(): Promise<void> {
|
||||||
|
const count = await getSessionCount()
|
||||||
|
if (count > MAX_SESSIONS) {
|
||||||
|
const toDelete = count - MAX_SESSIONS
|
||||||
|
for (let i = 0; i < toDelete; i++) {
|
||||||
|
await deleteOldestSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Create a new empty session
|
||||||
|
export function createEmptySession(): ChatSession {
|
||||||
|
return {
|
||||||
|
id: nanoid(),
|
||||||
|
title: "New Chat",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
xmlSnapshots: [],
|
||||||
|
diagramXml: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract title from first user message (truncated to reasonable length)
|
||||||
|
const MAX_TITLE_LENGTH = 100
|
||||||
|
|
||||||
|
export function extractTitle(messages: StoredMessage[]): string {
|
||||||
|
const firstUserMessage = messages.find((m) => m.role === "user")
|
||||||
|
if (!firstUserMessage) return "New Chat"
|
||||||
|
|
||||||
|
const textPart = firstUserMessage.parts.find((p) => p.type === "text")
|
||||||
|
if (!textPart || typeof textPart.text !== "string") return "New Chat"
|
||||||
|
|
||||||
|
const text = textPart.text.trim()
|
||||||
|
if (!text) return "New Chat"
|
||||||
|
|
||||||
|
// Truncate long titles
|
||||||
|
if (text.length > MAX_TITLE_LENGTH) {
|
||||||
|
return text.slice(0, MAX_TITLE_LENGTH).trim() + "..."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Sanitize UIMessage to StoredMessage
|
||||||
|
export function sanitizeMessage(message: unknown): StoredMessage | null {
|
||||||
|
if (!message || typeof message !== "object") return null
|
||||||
|
|
||||||
|
const msg = message as Record<string, unknown>
|
||||||
|
if (!msg.id || !msg.role) return null
|
||||||
|
|
||||||
|
const role = msg.role as string
|
||||||
|
if (!["user", "assistant", "system"].includes(role)) return null
|
||||||
|
|
||||||
|
// Extract parts, removing streaming state artifacts
|
||||||
|
let parts: Array<{ type: string; [key: string]: unknown }> = []
|
||||||
|
if (Array.isArray(msg.parts)) {
|
||||||
|
parts = msg.parts.map((part: unknown) => {
|
||||||
|
if (!part || typeof part !== "object") return { type: "unknown" }
|
||||||
|
const p = part as Record<string, unknown>
|
||||||
|
// Remove streaming-related fields
|
||||||
|
const { isStreaming, streamingState, ...cleanPart } = p
|
||||||
|
return cleanPart as { type: string; [key: string]: unknown }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.id as string,
|
||||||
|
role: role as "user" | "assistant" | "system",
|
||||||
|
parts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeMessages(messages: unknown[]): StoredMessage[] {
|
||||||
|
return messages
|
||||||
|
.map(sanitizeMessage)
|
||||||
|
.filter((m): m is StoredMessage => m !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration from localStorage
|
||||||
|
export async function migrateFromLocalStorage(): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
if (!isIndexedDBAvailable()) return null
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
if (localStorage.getItem(MIGRATION_FLAG)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedMessages = localStorage.getItem("next-ai-draw-io-messages")
|
||||||
|
const savedSnapshots = localStorage.getItem(
|
||||||
|
"next-ai-draw-io-xml-snapshots",
|
||||||
|
)
|
||||||
|
const savedXml = localStorage.getItem("next-ai-draw-io-diagram-xml")
|
||||||
|
|
||||||
|
let newSessionId: string | null = null
|
||||||
|
let migrationSucceeded = false
|
||||||
|
|
||||||
|
if (savedMessages) {
|
||||||
|
const messages = JSON.parse(savedMessages)
|
||||||
|
if (Array.isArray(messages) && messages.length > 0) {
|
||||||
|
const sanitized = sanitizeMessages(messages)
|
||||||
|
const session: ChatSession = {
|
||||||
|
id: nanoid(),
|
||||||
|
title: extractTitle(sanitized),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: sanitized,
|
||||||
|
xmlSnapshots: savedSnapshots
|
||||||
|
? JSON.parse(savedSnapshots)
|
||||||
|
: [],
|
||||||
|
diagramXml: savedXml || "",
|
||||||
|
}
|
||||||
|
const saved = await saveSession(session)
|
||||||
|
if (saved) {
|
||||||
|
// Verify the session was actually written
|
||||||
|
const verified = await getSession(session.id)
|
||||||
|
if (verified) {
|
||||||
|
newSessionId = session.id
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Empty array or invalid data - nothing to migrate, mark as success
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data to migrate - mark as success
|
||||||
|
migrationSucceeded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clean up old data if migration succeeded
|
||||||
|
if (migrationSucceeded) {
|
||||||
|
localStorage.setItem(MIGRATION_FLAG, "true")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-messages")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-xml-snapshots")
|
||||||
|
localStorage.removeItem("next-ai-draw-io-diagram-xml")
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"Migration to IndexedDB failed - keeping localStorage data for retry",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSessionId
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Migration failed:", error)
|
||||||
|
// Don't mark as migrated - allow retry on next load
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
// Centralized localStorage keys
|
// Centralized localStorage keys for quota tracking and settings
|
||||||
// Consolidates all storage keys from chat-panel.tsx and settings-dialog.tsx
|
// Chat data is now stored in IndexedDB via session-storage.ts
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
// Chat data
|
|
||||||
messages: "next-ai-draw-io-messages",
|
|
||||||
xmlSnapshots: "next-ai-draw-io-xml-snapshots",
|
|
||||||
diagramXml: "next-ai-draw-io-diagram-xml",
|
|
||||||
sessionId: "next-ai-draw-io-session-id",
|
|
||||||
|
|
||||||
// Quota tracking
|
// Quota tracking
|
||||||
requestCount: "next-ai-draw-io-request-count",
|
requestCount: "next-ai-draw-io-request-count",
|
||||||
requestDate: "next-ai-draw-io-request-date",
|
requestDate: "next-ai-draw-io-request-date",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ProviderName =
|
|||||||
| "gateway"
|
| "gateway"
|
||||||
| "edgeone"
|
| "edgeone"
|
||||||
| "doubao"
|
| "doubao"
|
||||||
|
| "modelscope"
|
||||||
|
|
||||||
// Individual model configuration
|
// Individual model configuration
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
@@ -91,6 +92,10 @@ export const PROVIDER_INFO: Record<
|
|||||||
label: "Doubao (ByteDance)",
|
label: "Doubao (ByteDance)",
|
||||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
},
|
},
|
||||||
|
modelscope: {
|
||||||
|
label: "ModelScope",
|
||||||
|
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggested models per provider for quick add
|
// Suggested models per provider for quick add
|
||||||
@@ -231,6 +236,17 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
|
|||||||
"doubao-pro-32k-241215",
|
"doubao-pro-32k-241215",
|
||||||
"doubao-pro-256k-241215",
|
"doubao-pro-256k-241215",
|
||||||
],
|
],
|
||||||
|
modelscope: [
|
||||||
|
// Qwen
|
||||||
|
"Qwen/Qwen2.5-72B-Instruct",
|
||||||
|
"Qwen/Qwen2.5-32B-Instruct",
|
||||||
|
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
|
"Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||||
|
"Qwen/Qwen3-32B",
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
|
"deepseek-ai/DeepSeek-V3.2",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to generate UUID
|
// Helper to generate UUID
|
||||||
|
|||||||
49
lib/url-utils.ts
Normal file
49
lib/url-utils.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export interface UrlData {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
charCount: number
|
||||||
|
isExtracting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const UrlResponseSchema = z.object({
|
||||||
|
title: z.string().default("Untitled"),
|
||||||
|
content: z.string(),
|
||||||
|
charCount: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||||
|
const response = await fetch("/api/parse-url", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to parse JSON once
|
||||||
|
const raw = await response
|
||||||
|
.json()
|
||||||
|
.catch(() => ({ error: "Unexpected non-JSON response" }))
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
typeof raw === "object" && raw && "error" in raw
|
||||||
|
? String((raw as any).error)
|
||||||
|
: "Failed to extract URL content"
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UrlResponseSchema.safeParse(raw)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error("Malformed response from URL extraction API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: parsed.data.title,
|
||||||
|
content: parsed.data.content,
|
||||||
|
charCount: parsed.data.charCount,
|
||||||
|
isExtracting: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/utils.ts
19
lib/utils.ts
@@ -6,6 +6,25 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Diagram Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length for a "real" diagram XML (not just empty template).
|
||||||
|
* Empty mxfile templates are ~147-300 chars; real diagrams are larger.
|
||||||
|
*/
|
||||||
|
export const MIN_REAL_DIAGRAM_LENGTH = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if diagram XML represents a real diagram (not just empty template).
|
||||||
|
* @param xml - The diagram XML string to check
|
||||||
|
* @returns true if the XML is a real diagram with content
|
||||||
|
*/
|
||||||
|
export function isRealDiagram(xml: string | undefined | null): boolean {
|
||||||
|
return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// XML Validation/Fix Constants
|
// XML Validation/Fix Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
5312
package-lock.json
generated
5312
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.7",
|
"version": "0.4.9",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
||||||
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
|
||||||
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
|
||||||
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
|
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@ai-sdk/react": "^3.0.1",
|
"@ai-sdk/react": "^3.0.1",
|
||||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
|
"@extractus/article-extractor": "^8.0.18",
|
||||||
"@formatjs/intl-localematcher": "^0.7.2",
|
"@formatjs/intl-localematcher": "^0.7.2",
|
||||||
"@langfuse/client": "^4.4.9",
|
"@langfuse/client": "^4.4.9",
|
||||||
"@langfuse/otel": "^4.4.4",
|
"@langfuse/otel": "^4.4.4",
|
||||||
@@ -64,14 +67,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
"jsdom": "^27.0.0",
|
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
|
"nanoid": "^3.3.11",
|
||||||
"negotiator": "^1.0.0",
|
"negotiator": "^1.0.0",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.7",
|
||||||
"ollama-ai-provider-v2": "^1.5.4",
|
"ollama-ai-provider-v2": "^2.0.0",
|
||||||
"pako": "^2.1.0",
|
"pako": "^2.1.0",
|
||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"react": "^19.1.2",
|
"react": "^19.1.2",
|
||||||
@@ -85,13 +89,14 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"turndown": "^7.2.0",
|
||||||
"unpdf": "^1.4.0",
|
"unpdf": "^1.4.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
|
||||||
"lightningcss": "^1.30.2",
|
"lightningcss": "^1.30.2",
|
||||||
"lightningcss-linux-x64-gnu": "^1.30.2",
|
"lightningcss-linux-x64-gnu": "^1.30.2"
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,json,css}": [
|
"*.{js,ts,jsx,tsx,json,css}": [
|
||||||
@@ -102,13 +107,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/negotiator": "^0.6.4",
|
"@types/negotiator": "^0.6.4",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
@@ -117,10 +129,13 @@
|
|||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
|
"vitest": "^4.0.16",
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.54.0"
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cline (VS Code Extension)
|
||||||
|
|
||||||
|
1. Click the **MCP Servers** icon in Cline's top menu bar
|
||||||
|
2. Select the **Configure** tab
|
||||||
|
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
|
||||||
|
4. Add the drawio server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"drawio": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@next-ai-drawio/mcp-server@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Claude Code CLI
|
### Claude Code CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@next-ai-drawio/mcp-server",
|
"name": "@next-ai-drawio/mcp-server",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"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",
|
||||||
@@ -21,16 +21,16 @@
|
|||||||
"claude",
|
"claude",
|
||||||
"model-context-protocol"
|
"model-context-protocol"
|
||||||
],
|
],
|
||||||
"author": "Biki-dev",
|
"author": "DayuanJiang",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Biki-dev/next-ai-draw-io",
|
"url": "https://github.com/DayuanJiang/next-ai-draw-io",
|
||||||
"directory": "packages/mcp-server"
|
"directory": "packages/mcp-server"
|
||||||
},
|
},
|
||||||
"homepage": "https://next-ai-drawio.jiang.jp",
|
"homepage": "https://next-ai-drawio.jiang.jp",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Biki-dev/next-ai-draw-io/issues"
|
"url": "https://github.com/DayuanJiang/next-ai-draw-io/issues"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -29,10 +29,27 @@ function getOrigin(url: string): string {
|
|||||||
|
|
||||||
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
||||||
|
|
||||||
|
// Minimal blank diagram used to bootstrap new sessions.
|
||||||
|
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
|
||||||
|
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|
||||||
// Normalize URL for iframe src - ensure no double slashes
|
// Normalize URL for iframe src - ensure no double slashes
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
// Remove trailing slash to avoid double slashes
|
// Remove trailing slash to avoid double slashes
|
||||||
return url.replace(/\/$/, '')
|
return url.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyMcpSessionId(sessionId: string): boolean {
|
||||||
|
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
|
||||||
|
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSessionStateInitialized(sessionId: string): void {
|
||||||
|
if (!sessionId) return
|
||||||
|
if (!isLikelyMcpSessionId(sessionId)) return
|
||||||
|
if (stateStore.has(sessionId)) return
|
||||||
|
|
||||||
|
setState(sessionId, DEFAULT_DIAGRAM_XML)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
@@ -177,8 +194,11 @@ function handleRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
|
const sessionId = url.searchParams.get("mcp") || ""
|
||||||
|
ensureSessionStateInitialized(sessionId)
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "text/html" })
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
|
res.end(getHtmlPage(sessionId))
|
||||||
} else if (url.pathname === "/api/state") {
|
} else if (url.pathname === "/api/state") {
|
||||||
handleStateApi(req, res, url)
|
handleStateApi(req, res, url)
|
||||||
} else if (url.pathname === "/api/history") {
|
} else if (url.pathname === "/api/history") {
|
||||||
@@ -205,6 +225,7 @@ function handleStateApi(
|
|||||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ensureSessionStateInitialized(sessionId)
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(
|
res.end(
|
||||||
|
|||||||
28
playwright.config.ts
Normal file
28
playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: process.env.CI ? [["list"], ["html"]] : "html",
|
||||||
|
webServer: {
|
||||||
|
command: process.env.CI ? "npm run start" : "npm run dev",
|
||||||
|
port: process.env.CI ? 6001 : 6002,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.CI
|
||||||
|
? "http://localhost:6001"
|
||||||
|
: "http://localhost:6002",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { browserName: "chromium" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
22
tests/e2e/chat.spec.ts
Normal file
22
tests/e2e/chat.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, getIframe, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Chat Panel", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("page has interactive elements", async ({ page }) => {
|
||||||
|
const buttons = page.locator("button")
|
||||||
|
const count = await buttons.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("draw.io iframe is interactive", async ({ page }) => {
|
||||||
|
const iframe = getIframe(page)
|
||||||
|
await expect(iframe).toBeVisible()
|
||||||
|
|
||||||
|
const src = await iframe.getAttribute("src")
|
||||||
|
expect(src).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
137
tests/e2e/copy-paste.spec.ts
Normal file
137
tests/e2e/copy-paste.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createMockSSEResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("Copy/Paste Functionality", () => {
|
||||||
|
test("can paste text into chat input", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await chatInput.focus()
|
||||||
|
await page.keyboard.insertText("Create a flowchart diagram")
|
||||||
|
|
||||||
|
await expect(chatInput).toHaveValue("Create a flowchart diagram")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can paste multiline text into chat input", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await chatInput.focus()
|
||||||
|
const multilineText = "Line 1\nLine 2\nLine 3"
|
||||||
|
await page.keyboard.insertText(multilineText)
|
||||||
|
|
||||||
|
await expect(chatInput).toHaveValue(multilineText)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("copy button copies response text", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"Here is your diagram with a test box.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Create a test box")
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
await expect(
|
||||||
|
page.locator('text="Here is your diagram with a test box."'),
|
||||||
|
).toBeVisible({ timeout: 15000 })
|
||||||
|
|
||||||
|
// Find copy button in message
|
||||||
|
const copyButton = page.locator(
|
||||||
|
'[data-testid="copy-button"], button[aria-label*="Copy"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copy button feature may not exist - skip if not available
|
||||||
|
const buttonCount = await copyButton.count()
|
||||||
|
if (buttonCount === 0) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await copyButton.first().click()
|
||||||
|
await expect(
|
||||||
|
page.locator('text="Copied"').or(page.locator("svg.lucide-check")),
|
||||||
|
).toBeVisible({ timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard shortcuts work in chat input", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await chatInput.fill("Hello world")
|
||||||
|
await chatInput.press("ControlOrMeta+a")
|
||||||
|
await chatInput.fill("New text")
|
||||||
|
|
||||||
|
await expect(chatInput).toHaveValue("New text")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can undo/redo in chat input", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await chatInput.fill("First text")
|
||||||
|
await chatInput.press("Tab")
|
||||||
|
|
||||||
|
await chatInput.focus()
|
||||||
|
await chatInput.fill("Second text")
|
||||||
|
await chatInput.press("ControlOrMeta+z")
|
||||||
|
|
||||||
|
// Verify page is still functional after undo
|
||||||
|
await expect(chatInput).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("chat input handles special characters", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
const specialText = "Test <>&\"' special chars 日本語 中文 🎉"
|
||||||
|
await chatInput.fill(specialText)
|
||||||
|
|
||||||
|
await expect(chatInput).toHaveValue(specialText)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("long text in chat input scrolls", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
const longText = "This is a very long text. ".repeat(50)
|
||||||
|
await chatInput.fill(longText)
|
||||||
|
|
||||||
|
const value = await chatInput.inputValue()
|
||||||
|
expect(value.length).toBeGreaterThan(500)
|
||||||
|
})
|
||||||
|
})
|
||||||
128
tests/e2e/diagram-generation.spec.ts
Normal file
128
tests/e2e/diagram-generation.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
CAT_DIAGRAM_XML,
|
||||||
|
FLOWCHART_XML,
|
||||||
|
NEW_NODE_XML,
|
||||||
|
} from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
createMultiTurnMock,
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
waitForComplete,
|
||||||
|
waitForCompleteCount,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createMockSSEResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("Diagram Generation", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
CAT_DIAGRAM_XML,
|
||||||
|
"I'll create a diagram for you.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generates and displays a diagram", async ({ page }) => {
|
||||||
|
await sendMessage(page, "Draw a cat")
|
||||||
|
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
await waitForComplete(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("chat input clears after sending", async ({ page }) => {
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await chatInput.fill("Draw a cat")
|
||||||
|
await chatInput.press("ControlOrMeta+Enter")
|
||||||
|
|
||||||
|
await expect(chatInput).toHaveValue("", { timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("user message appears in chat", async ({ page }) => {
|
||||||
|
await sendMessage(page, "Draw a cute cat")
|
||||||
|
await expect(page.locator('text="Draw a cute cat"')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assistant text message appears in chat", async ({ page }) => {
|
||||||
|
await sendMessage(page, "Draw a cat")
|
||||||
|
await expect(
|
||||||
|
page.locator('text="I\'ll create a diagram for you."'),
|
||||||
|
).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Diagram Edit", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createMultiTurnMock([
|
||||||
|
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
|
||||||
|
{
|
||||||
|
xml: FLOWCHART_XML.replace("Process", "Updated Process"),
|
||||||
|
text: "I'll create a diagram for you.",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can edit an existing diagram", async ({ page }) => {
|
||||||
|
// First: create initial diagram
|
||||||
|
await sendMessage(page, "Create a flowchart")
|
||||||
|
await waitForComplete(page)
|
||||||
|
|
||||||
|
// Second: edit the diagram
|
||||||
|
await sendMessage(page, "Change Process to Updated Process")
|
||||||
|
await waitForCompleteCount(page, 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe("Diagram Append", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createMultiTurnMock([
|
||||||
|
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
|
||||||
|
{
|
||||||
|
xml: NEW_NODE_XML,
|
||||||
|
text: "I'll create a diagram for you.",
|
||||||
|
toolName: "append_diagram",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can append to an existing diagram", async ({ page }) => {
|
||||||
|
// First: create initial diagram
|
||||||
|
await sendMessage(page, "Create a flowchart")
|
||||||
|
await waitForComplete(page)
|
||||||
|
|
||||||
|
// Second: append to diagram
|
||||||
|
await sendMessage(page, "Add a new node to the right")
|
||||||
|
await waitForCompleteCount(page, 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
136
tests/e2e/error-handling.spec.ts
Normal file
136
tests/e2e/error-handling.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { TRUNCATED_XML } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
createErrorMock,
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Error Handling", () => {
|
||||||
|
test("displays error message when API returns 500", async ({ page }) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createErrorMock(500, "Internal server error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Draw a cat")
|
||||||
|
|
||||||
|
// Should show error indication
|
||||||
|
const errorIndicator = page
|
||||||
|
.locator('[role="alert"]')
|
||||||
|
.or(page.locator("[data-sonner-toast]"))
|
||||||
|
.or(page.locator("text=/error|failed|something went wrong/i"))
|
||||||
|
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// User should be able to type again
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await chatInput.fill("Retry message")
|
||||||
|
await expect(chatInput).toHaveValue("Retry message")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("displays error message when API returns 429 rate limit", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createErrorMock(429, "Rate limit exceeded"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Draw a cat")
|
||||||
|
|
||||||
|
// Should show error indication for rate limit
|
||||||
|
const errorIndicator = page
|
||||||
|
.locator('[role="alert"]')
|
||||||
|
.or(page.locator("[data-sonner-toast]"))
|
||||||
|
.or(page.locator("text=/rate limit|too many|try again/i"))
|
||||||
|
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// User should be able to type again
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await chatInput.fill("Retry after rate limit")
|
||||||
|
await expect(chatInput).toHaveValue("Retry after rate limit")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles network timeout gracefully", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
await route.abort("timedout")
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Draw a cat")
|
||||||
|
|
||||||
|
// Should show error indication for network failure
|
||||||
|
const errorIndicator = page
|
||||||
|
.locator('[role="alert"]')
|
||||||
|
.or(page.locator("[data-sonner-toast]"))
|
||||||
|
.or(page.locator("text=/error|failed|network|timeout/i"))
|
||||||
|
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
// After timeout, user should be able to type again
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await chatInput.fill("Try again after timeout")
|
||||||
|
await expect(chatInput).toHaveValue("Try again after timeout")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows truncated badge for incomplete XML", async ({ page }) => {
|
||||||
|
const toolCallId = `call_${Date.now()}`
|
||||||
|
const textId = `text_${Date.now()}`
|
||||||
|
const messageId = `msg_${Date.now()}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ type: "start", messageId },
|
||||||
|
{ type: "text-start", id: textId },
|
||||||
|
{ type: "text-delta", id: textId, delta: "Creating diagram..." },
|
||||||
|
{ type: "text-end", id: textId },
|
||||||
|
{
|
||||||
|
type: "tool-input-start",
|
||||||
|
toolCallId,
|
||||||
|
toolName: "display_diagram",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-input-available",
|
||||||
|
toolCallId,
|
||||||
|
toolName: "display_diagram",
|
||||||
|
input: { xml: TRUNCATED_XML },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-output-error",
|
||||||
|
toolCallId,
|
||||||
|
error: "XML validation failed",
|
||||||
|
},
|
||||||
|
{ type: "finish" },
|
||||||
|
]
|
||||||
|
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body:
|
||||||
|
events
|
||||||
|
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
|
||||||
|
.join("") + "data: [DONE]\n\n",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Draw something")
|
||||||
|
|
||||||
|
// Should show truncated badge
|
||||||
|
await expect(page.locator('text="Truncated"')).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
152
tests/e2e/file-upload.spec.ts
Normal file
152
tests/e2e/file-upload.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createMockSSEResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("File Upload", () => {
|
||||||
|
test("upload button opens file picker", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const uploadButton = page.locator(
|
||||||
|
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
|
||||||
|
)
|
||||||
|
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(uploadButton.first()).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows file preview after selecting image", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "test-image.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="alert"][data-type="error"]'),
|
||||||
|
).not.toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can remove uploaded file", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "test-image.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="alert"][data-type="error"]'),
|
||||||
|
).not.toBeVisible({ timeout: 2000 })
|
||||||
|
|
||||||
|
const removeButton = page.locator(
|
||||||
|
'[data-testid="remove-file-button"], button[aria-label*="Remove"], button:has(svg.lucide-x)',
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeButtonCount = await removeButton.count()
|
||||||
|
if (removeButtonCount === 0) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeButton.first().click()
|
||||||
|
await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sends file with message to API", async ({ page }) => {
|
||||||
|
let capturedRequest: any = null
|
||||||
|
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
capturedRequest = route.request()
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"Based on your image, here is a diagram:",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "architecture.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
"base64",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendMessage(page, "Convert this to a diagram")
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text="Based on your image, here is a diagram:"'),
|
||||||
|
).toBeVisible({ timeout: 15000 })
|
||||||
|
|
||||||
|
expect(capturedRequest).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows error for oversized file", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
|
||||||
|
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "large-image.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: largeBuffer,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="alert"], [data-sonner-toast]').first(),
|
||||||
|
).toBeVisible({ timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drag and drop file upload works", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatForm = page.locator("form").first()
|
||||||
|
|
||||||
|
const dataTransfer = await page.evaluateHandle(() => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const file = new File(["test content"], "dropped-image.png", {
|
||||||
|
type: "image/png",
|
||||||
|
})
|
||||||
|
dt.items.add(file)
|
||||||
|
return dt
|
||||||
|
})
|
||||||
|
|
||||||
|
await chatForm.dispatchEvent("dragover", { dataTransfer })
|
||||||
|
await chatForm.dispatchEvent("drop", { dataTransfer })
|
||||||
|
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
50
tests/e2e/fixtures/diagrams.ts
Normal file
50
tests/e2e/fixtures/diagrams.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Shared XML diagram fixtures for E2E tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Simple cat diagram
|
||||||
|
export const CAT_DIAGRAM_XML = `<mxCell id="cat-head" value="Cat Head" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="200" y="100" width="100" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="cat-body" value="Cat Body" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="180" y="180" width="140" height="100" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// Simple flowchart
|
||||||
|
export const FLOWCHART_XML = `<mxCell id="start" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="200" y="50" width="100" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="process" value="Process" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="200" y="130" width="100" height="40" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="end" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="200" y="210" width="100" height="40" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// Simple single box
|
||||||
|
export const SINGLE_BOX_XML = `<mxCell id="box" value="Test Box" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// Test node for iframe interaction tests
|
||||||
|
export const TEST_NODE_XML = `<mxCell id="test-node-123" value="Test Node" style="rounded=1;fillColor=#d5e8d4;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// Architecture box
|
||||||
|
export const ARCHITECTURE_XML = `<mxCell id="arch" value="Architecture" style="rounded=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="50" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// New node for append tests
|
||||||
|
export const NEW_NODE_XML = `<mxCell id="new-node" value="New Node" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="350" y="130" width="100" height="40" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
|
||||||
|
// Truncated XML for error tests
|
||||||
|
export const TRUNCATED_XML = `<mxCell id="node1" value="Start" style="rounded=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="100" height="40"`
|
||||||
|
|
||||||
|
// Simple boxes for multi-turn tests
|
||||||
|
export const createBoxXml = (id: string, label: string, y = 100) =>
|
||||||
|
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="${y}" width="100" height="40" as="geometry"/></mxCell>`
|
||||||
215
tests/e2e/history-restore.spec.ts
Normal file
215
tests/e2e/history-restore.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
expectBeforeAndAfterReload,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
getIframeContent,
|
||||||
|
openSettings,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
waitForComplete,
|
||||||
|
waitForText,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createMockSSEResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("History and Session Restore", () => {
|
||||||
|
test("new chat button clears conversation", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"Created your test diagram.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await test.step("create a conversation", async () => {
|
||||||
|
await sendMessage(page, "Create a test diagram")
|
||||||
|
await waitForText(page, "Created your test diagram.")
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("click new chat button", async () => {
|
||||||
|
const newChatButton = page.locator(
|
||||||
|
'[data-testid="new-chat-button"]',
|
||||||
|
)
|
||||||
|
await expect(newChatButton).toBeVisible({ timeout: 5000 })
|
||||||
|
await newChatButton.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify conversation is cleared", async () => {
|
||||||
|
await expect(
|
||||||
|
page.locator('text="Created your test diagram."'),
|
||||||
|
).not.toBeVisible({ timeout: 5000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("chat history sidebar shows past conversations", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const historyButton = page.locator(
|
||||||
|
'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])',
|
||||||
|
)
|
||||||
|
|
||||||
|
const buttonCount = await historyButton.count()
|
||||||
|
if (buttonCount === 0) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await historyButton.first().click()
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("conversation persists after page reload", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"This message should persist.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await test.step("create conversation", async () => {
|
||||||
|
await sendMessage(page, "Create persistent diagram")
|
||||||
|
await waitForText(page, "This message should persist.")
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify message appears before reload", async () => {
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(
|
||||||
|
page.locator('text="This message should persist."'),
|
||||||
|
).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: After reload, mocked responses won't persist since we're not
|
||||||
|
// testing with real localStorage. We just verify the app loads correctly.
|
||||||
|
await test.step("verify app loads after reload", async () => {
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("diagram state persists after reload", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"Created a diagram that should be saved.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Create saveable diagram")
|
||||||
|
await waitForComplete(page)
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const frame = getIframeContent(page)
|
||||||
|
await expect(
|
||||||
|
frame
|
||||||
|
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can restore from browser back/forward", async ({ page }) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
SINGLE_BOX_XML,
|
||||||
|
"Testing browser navigation.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Test navigation")
|
||||||
|
await waitForText(page, "Testing browser navigation.")
|
||||||
|
|
||||||
|
await page.goto("/about", { waitUntil: "networkidle" })
|
||||||
|
await page.goBack({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settings are restored after reload", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("model selection persists", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const modelSelector = page.locator(
|
||||||
|
'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")',
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectorCount = await modelSelector.count()
|
||||||
|
if (selectorCount === 0) {
|
||||||
|
test.skip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialModel = await modelSelector.first().textContent()
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const modelAfterReload = await modelSelector.first().textContent()
|
||||||
|
expect(modelAfterReload).toBe(initialModel)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles localStorage quota exceeded gracefully", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
try {
|
||||||
|
const largeData = "x".repeat(5 * 1024 * 1024)
|
||||||
|
localStorage.setItem("test-large-data", largeData)
|
||||||
|
} catch {
|
||||||
|
// Expected to fail on some browsers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.removeItem("test-large-data")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
18
tests/e2e/history.spec.ts
Normal file
18
tests/e2e/history.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, getIframe, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("History Dialog", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("history button exists in UI", async ({ page }) => {
|
||||||
|
// History button may be disabled initially (no history)
|
||||||
|
// Just verify it exists in the DOM
|
||||||
|
const historyButton = page
|
||||||
|
.locator("button")
|
||||||
|
.filter({ has: page.locator("svg") })
|
||||||
|
const count = await historyButton.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
122
tests/e2e/iframe-interaction.spec.ts
Normal file
122
tests/e2e/iframe-interaction.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { TEST_NODE_XML } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
getIframeContent,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
waitForComplete,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createMockSSEResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("Iframe Interaction", () => {
|
||||||
|
test("draw.io iframe loads successfully", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
|
||||||
|
const iframe = getIframe(page)
|
||||||
|
await expect(iframe).toBeVisible({ timeout: 30000 })
|
||||||
|
|
||||||
|
// iframe should have loaded draw.io content
|
||||||
|
const frame = getIframeContent(page)
|
||||||
|
await expect(
|
||||||
|
frame
|
||||||
|
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can interact with draw.io toolbar", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const frame = getIframeContent(page)
|
||||||
|
|
||||||
|
// Draw.io menu items should be accessible
|
||||||
|
await expect(
|
||||||
|
frame
|
||||||
|
.locator('text="Diagram"')
|
||||||
|
.or(frame.locator('[title*="Diagram"]')),
|
||||||
|
).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("diagram XML is rendered in iframe after generation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
TEST_NODE_XML,
|
||||||
|
"Here is your diagram:",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await sendMessage(page, "Create a test node")
|
||||||
|
await waitForComplete(page)
|
||||||
|
|
||||||
|
// Give draw.io time to render
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("zoom controls work in draw.io", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const frame = getIframeContent(page)
|
||||||
|
|
||||||
|
// draw.io should be loaded and functional - check for diagram container
|
||||||
|
await expect(
|
||||||
|
frame.locator(".geDiagramContainer, canvas").first(),
|
||||||
|
).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can resize the panel divider", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
// Find the resizer/divider between panels
|
||||||
|
const resizer = page.locator(
|
||||||
|
'[role="separator"], [data-panel-resize-handle-id], .resize-handle',
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((await resizer.count()) > 0) {
|
||||||
|
await expect(resizer.first()).toBeVisible()
|
||||||
|
|
||||||
|
const box = await resizer.first().boundingBox()
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(
|
||||||
|
box.x + box.width / 2,
|
||||||
|
box.y + box.height / 2,
|
||||||
|
)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(box.x + 50, box.y + box.height / 2)
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("iframe responds to window resize", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const iframe = getIframe(page)
|
||||||
|
const initialBox = await iframe.boundingBox()
|
||||||
|
|
||||||
|
// Resize window
|
||||||
|
await page.setViewportSize({ width: 800, height: 600 })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
const newBox = await iframe.boundingBox()
|
||||||
|
|
||||||
|
expect(newBox).toBeDefined()
|
||||||
|
if (initialBox && newBox) {
|
||||||
|
expect(newBox.width).toBeLessThanOrEqual(800)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
26
tests/e2e/keyboard.spec.ts
Normal file
26
tests/e2e/keyboard.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Keyboard Interactions", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Escape closes settings dialog", async ({ page }) => {
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const dialog = page.locator('[role="dialog"]')
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 2000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("page is keyboard accessible", async ({ page }) => {
|
||||||
|
const focusableElements = page.locator(
|
||||||
|
'button, [tabindex="0"], input, textarea, a[href]',
|
||||||
|
)
|
||||||
|
const count = await focusableElements.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
105
tests/e2e/language.spec.ts
Normal file
105
tests/e2e/language.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
expectBeforeAndAfterReload,
|
||||||
|
getChatInput,
|
||||||
|
getIframe,
|
||||||
|
openSettings,
|
||||||
|
sleep,
|
||||||
|
test,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Language Switching", () => {
|
||||||
|
test("loads English by default", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
|
||||||
|
await expect(page.locator('button:has-text("Send")')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can switch to Japanese", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await test.step("open settings and select Japanese", async () => {
|
||||||
|
await openSettings(page)
|
||||||
|
const languageSelector = page.locator('button:has-text("English")')
|
||||||
|
await languageSelector.first().click()
|
||||||
|
await page.locator('text="日本語"').click()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify UI is in Japanese", async () => {
|
||||||
|
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can switch to Chinese", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await test.step("open settings and select Chinese", async () => {
|
||||||
|
await openSettings(page)
|
||||||
|
const languageSelector = page.locator('button:has-text("English")')
|
||||||
|
await languageSelector.first().click()
|
||||||
|
await page.locator('text="中文"').click()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify UI is in Chinese", async () => {
|
||||||
|
await expect(page.locator('button:has-text("发送")')).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("language persists after reload", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await test.step("switch to Japanese", async () => {
|
||||||
|
await openSettings(page)
|
||||||
|
const languageSelector = page.locator('button:has-text("English")')
|
||||||
|
await languageSelector.first().click()
|
||||||
|
await page.locator('text="日本語"').click()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await sleep(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify Japanese before reload", async () => {
|
||||||
|
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("reload and verify Japanese persists", async () => {
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
// Wait for hydration and localStorage to be read
|
||||||
|
await sleep(1000)
|
||||||
|
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Japanese locale URL works", async ({ page }) => {
|
||||||
|
await page.goto("/ja", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Chinese locale URL works", async ({ page }) => {
|
||||||
|
await page.goto("/zh", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await expect(page.locator('button:has-text("发送")')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
208
tests/e2e/lib/fixtures.ts
Normal file
208
tests/e2e/lib/fixtures.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Playwright test fixtures for E2E tests
|
||||||
|
* Uses test.extend to provide common setup and helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test as base, expect, type Page, type Route } from "@playwright/test"
|
||||||
|
import { createMockSSEResponse, createTextOnlyResponse } from "./helpers"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended test with common fixtures
|
||||||
|
*/
|
||||||
|
export const test = base.extend<{
|
||||||
|
/** Page with iframe already loaded */
|
||||||
|
appPage: Page
|
||||||
|
}>({
|
||||||
|
appPage: async ({ page }, use) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
await use(page)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { expect }
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Locator helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** Get the chat input textarea */
|
||||||
|
export function getChatInput(page: Page) {
|
||||||
|
return page.locator('textarea[aria-label="Chat input"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the draw.io iframe */
|
||||||
|
export function getIframe(page: Page) {
|
||||||
|
return page.locator("iframe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the iframe's frame locator for internal queries */
|
||||||
|
export function getIframeContent(page: Page) {
|
||||||
|
return page.frameLocator("iframe")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the settings button */
|
||||||
|
export function getSettingsButton(page: Page) {
|
||||||
|
return page.locator('[data-testid="settings-button"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Action helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** Send a message in the chat input */
|
||||||
|
export async function sendMessage(page: Page, message: string) {
|
||||||
|
const chatInput = getChatInput(page)
|
||||||
|
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||||
|
await chatInput.fill(message)
|
||||||
|
await chatInput.press("ControlOrMeta+Enter")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for diagram generation to complete */
|
||||||
|
export async function waitForComplete(page: Page, timeout = 15000) {
|
||||||
|
await expect(page.locator('text="Complete"')).toBeVisible({ timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for N "Complete" badges */
|
||||||
|
export async function waitForCompleteCount(
|
||||||
|
page: Page,
|
||||||
|
count: number,
|
||||||
|
timeout = 15000,
|
||||||
|
) {
|
||||||
|
await expect(page.locator('text="Complete"')).toHaveCount(count, {
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for a specific text to appear */
|
||||||
|
export async function waitForText(page: Page, text: string, timeout = 15000) {
|
||||||
|
await expect(page.locator(`text="${text}"`)).toBeVisible({ timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open settings dialog */
|
||||||
|
export async function openSettings(page: Page) {
|
||||||
|
await getSettingsButton(page).click()
|
||||||
|
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Mock helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface MockResponse {
|
||||||
|
xml: string
|
||||||
|
text: string
|
||||||
|
toolName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a multi-turn mock handler
|
||||||
|
* Each request gets the next response in the array
|
||||||
|
*/
|
||||||
|
export function createMultiTurnMock(responses: MockResponse[]) {
|
||||||
|
let requestCount = 0
|
||||||
|
return async (route: Route) => {
|
||||||
|
const response =
|
||||||
|
responses[requestCount] || responses[responses.length - 1]
|
||||||
|
requestCount++
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(
|
||||||
|
response.xml,
|
||||||
|
response.text,
|
||||||
|
response.toolName,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock that returns text-only responses
|
||||||
|
*/
|
||||||
|
export function createTextOnlyMock(responses: string[]) {
|
||||||
|
let requestCount = 0
|
||||||
|
return async (route: Route) => {
|
||||||
|
const text = responses[requestCount] || responses[responses.length - 1]
|
||||||
|
requestCount++
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createTextOnlyResponse(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock that alternates between text and diagram responses
|
||||||
|
*/
|
||||||
|
export function createMixedMock(
|
||||||
|
responses: Array<
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "diagram"; xml: string; text: string }
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let requestCount = 0
|
||||||
|
return async (route: Route) => {
|
||||||
|
const response =
|
||||||
|
responses[requestCount] || responses[responses.length - 1]
|
||||||
|
requestCount++
|
||||||
|
if (response.type === "text") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createTextOnlyResponse(response.text),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createMockSSEResponse(response.xml, response.text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock that returns an error
|
||||||
|
*/
|
||||||
|
export function createErrorMock(status: number, error: string) {
|
||||||
|
return async (route: Route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ error }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Persistence helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that state persists across page reload.
|
||||||
|
* Runs assertions before reload, reloads page, then runs assertions again.
|
||||||
|
* Keep assertions narrow and explicit - test one specific thing.
|
||||||
|
*
|
||||||
|
* @param page - Playwright page
|
||||||
|
* @param description - What persistence is being tested (for debugging)
|
||||||
|
* @param assertion - Async function with expect() calls
|
||||||
|
*/
|
||||||
|
export async function expectBeforeAndAfterReload(
|
||||||
|
page: Page,
|
||||||
|
description: string,
|
||||||
|
assertion: () => Promise<void>,
|
||||||
|
) {
|
||||||
|
await test.step(`verify ${description} before reload`, assertion)
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
await test.step(`verify ${description} after reload`, assertion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple sleep helper */
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
88
tests/e2e/lib/helpers.ts
Normal file
88
tests/e2e/lib/helpers.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Shared test helpers for E2E tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock SSE response for the chat API
|
||||||
|
* Format matches AI SDK UI message stream protocol
|
||||||
|
*/
|
||||||
|
export function createMockSSEResponse(
|
||||||
|
xml: string,
|
||||||
|
text: string,
|
||||||
|
toolName = "display_diagram",
|
||||||
|
) {
|
||||||
|
const messageId = `msg_${Date.now()}`
|
||||||
|
const toolCallId = `call_${Date.now()}`
|
||||||
|
const textId = `text_${Date.now()}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ type: "start", messageId },
|
||||||
|
{ type: "text-start", id: textId },
|
||||||
|
{ type: "text-delta", id: textId, delta: text },
|
||||||
|
{ type: "text-end", id: textId },
|
||||||
|
{ type: "tool-input-start", toolCallId, toolName },
|
||||||
|
{ type: "tool-input-available", toolCallId, toolName, input: { xml } },
|
||||||
|
{
|
||||||
|
type: "tool-output-available",
|
||||||
|
toolCallId,
|
||||||
|
output: "Successfully displayed the diagram",
|
||||||
|
},
|
||||||
|
{ type: "finish" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a text-only SSE response (no tool call)
|
||||||
|
*/
|
||||||
|
export function createTextOnlyResponse(text: string) {
|
||||||
|
const messageId = `msg_${Date.now()}`
|
||||||
|
const textId = `text_${Date.now()}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ type: "start", messageId },
|
||||||
|
{ type: "text-start", id: textId },
|
||||||
|
{ type: "text-delta", id: textId, delta: text },
|
||||||
|
{ type: "text-end", id: textId },
|
||||||
|
{ type: "finish" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock SSE response with a tool error
|
||||||
|
*/
|
||||||
|
export function createToolErrorResponse(text: string, errorMessage: string) {
|
||||||
|
const messageId = `msg_${Date.now()}`
|
||||||
|
const toolCallId = `call_${Date.now()}`
|
||||||
|
const textId = `text_${Date.now()}`
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ type: "start", messageId },
|
||||||
|
{ type: "text-start", id: textId },
|
||||||
|
{ type: "text-delta", id: textId, delta: text },
|
||||||
|
{ type: "text-end", id: textId },
|
||||||
|
{ type: "tool-input-start", toolCallId, toolName: "display_diagram" },
|
||||||
|
{
|
||||||
|
type: "tool-input-available",
|
||||||
|
toolCallId,
|
||||||
|
toolName: "display_diagram",
|
||||||
|
input: { xml: "<invalid>" },
|
||||||
|
},
|
||||||
|
{ type: "tool-output-error", toolCallId, error: errorMessage },
|
||||||
|
{ type: "finish" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
19
tests/e2e/model-config.spec.ts
Normal file
19
tests/e2e/model-config.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Model Configuration", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settings dialog opens and shows configuration options", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const dialog = page.locator('[role="dialog"]')
|
||||||
|
const buttons = dialog.locator("button")
|
||||||
|
const buttonCount = await buttons.count()
|
||||||
|
expect(buttonCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
113
tests/e2e/multi-turn.spec.ts
Normal file
113
tests/e2e/multi-turn.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { ARCHITECTURE_XML, createBoxXml } from "./fixtures/diagrams"
|
||||||
|
import {
|
||||||
|
createMixedMock,
|
||||||
|
createMultiTurnMock,
|
||||||
|
expect,
|
||||||
|
getChatInput,
|
||||||
|
sendMessage,
|
||||||
|
test,
|
||||||
|
waitForComplete,
|
||||||
|
waitForText,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
import { createTextOnlyResponse } from "./lib/helpers"
|
||||||
|
|
||||||
|
test.describe("Multi-turn Conversation", () => {
|
||||||
|
test("handles multiple diagram requests in sequence", async ({ page }) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createMultiTurnMock([
|
||||||
|
{
|
||||||
|
xml: createBoxXml("box1", "First"),
|
||||||
|
text: "Creating diagram 1...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xml: createBoxXml("box2", "Second", 200),
|
||||||
|
text: "Creating diagram 2...",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
// First request
|
||||||
|
await sendMessage(page, "Draw first box")
|
||||||
|
await waitForText(page, "Creating diagram 1...")
|
||||||
|
|
||||||
|
// Second request
|
||||||
|
await sendMessage(page, "Draw second box")
|
||||||
|
await waitForText(page, "Creating diagram 2...")
|
||||||
|
|
||||||
|
// Both messages should be visible
|
||||||
|
await expect(page.locator('text="Draw first box"')).toBeVisible()
|
||||||
|
await expect(page.locator('text="Draw second box"')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves conversation history", async ({ page }) => {
|
||||||
|
let requestCount = 0
|
||||||
|
await page.route("**/api/chat", async (route) => {
|
||||||
|
requestCount++
|
||||||
|
const request = route.request()
|
||||||
|
const body = JSON.parse(request.postData() || "{}")
|
||||||
|
|
||||||
|
// Verify messages array grows with each request
|
||||||
|
if (requestCount === 2) {
|
||||||
|
expect(body.messages?.length).toBeGreaterThan(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: createTextOnlyResponse(`Response ${requestCount}`),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
// First message
|
||||||
|
await sendMessage(page, "Hello")
|
||||||
|
await waitForText(page, "Response 1")
|
||||||
|
|
||||||
|
// Second message (should include history)
|
||||||
|
await sendMessage(page, "Follow up question")
|
||||||
|
await waitForText(page, "Response 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can continue after a text-only response", async ({ page }) => {
|
||||||
|
await page.route(
|
||||||
|
"**/api/chat",
|
||||||
|
createMixedMock([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "I understand. Let me explain the architecture first.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "diagram",
|
||||||
|
xml: ARCHITECTURE_XML,
|
||||||
|
text: "Here is the diagram:",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await page
|
||||||
|
.locator("iframe")
|
||||||
|
.waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
// Ask for explanation first
|
||||||
|
await sendMessage(page, "Explain the architecture")
|
||||||
|
await waitForText(
|
||||||
|
page,
|
||||||
|
"I understand. Let me explain the architecture first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then ask for diagram
|
||||||
|
await sendMessage(page, "Now show it as a diagram")
|
||||||
|
await waitForComplete(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
16
tests/e2e/save.spec.ts
Normal file
16
tests/e2e/save.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, getIframe, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Save Dialog", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("save/download buttons exist", async ({ page }) => {
|
||||||
|
const buttons = page
|
||||||
|
.locator("button")
|
||||||
|
.filter({ has: page.locator("svg") })
|
||||||
|
const count = await buttons.count()
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
34
tests/e2e/settings.spec.ts
Normal file
34
tests/e2e/settings.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
getIframe,
|
||||||
|
getSettingsButton,
|
||||||
|
openSettings,
|
||||||
|
test,
|
||||||
|
} from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Settings", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settings dialog opens", async ({ page }) => {
|
||||||
|
await openSettings(page)
|
||||||
|
// openSettings already verifies dialog is visible
|
||||||
|
})
|
||||||
|
|
||||||
|
test("language selection is available", async ({ page }) => {
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const dialog = page.locator('[role="dialog"]')
|
||||||
|
await expect(dialog.locator('text="English"')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("draw.io theme toggle exists", async ({ page }) => {
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const dialog = page.locator('[role="dialog"]')
|
||||||
|
const themeText = dialog.locator("text=/sketch|minimal/i")
|
||||||
|
await expect(themeText.first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
36
tests/e2e/smoke.spec.ts
Normal file
36
tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Smoke Tests", () => {
|
||||||
|
test("homepage loads without errors", async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on("pageerror", (err) => errors.push(err.message))
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
|
||||||
|
|
||||||
|
const iframe = getIframe(page)
|
||||||
|
await expect(iframe).toBeVisible({ timeout: 30000 })
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Japanese locale page loads", async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on("pageerror", (err) => errors.push(err.message))
|
||||||
|
|
||||||
|
await page.goto("/ja", { waitUntil: "networkidle" })
|
||||||
|
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
|
||||||
|
|
||||||
|
const iframe = getIframe(page)
|
||||||
|
await expect(iframe).toBeVisible({ timeout: 30000 })
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("settings dialog opens", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
88
tests/e2e/theme.spec.ts
Normal file
88
tests/e2e/theme.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { expect, getIframe, openSettings, sleep, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("Theme Switching", () => {
|
||||||
|
test("can toggle app dark mode", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const html = page.locator("html")
|
||||||
|
const initialClass = await html.getAttribute("class")
|
||||||
|
|
||||||
|
const themeButton = page.locator(
|
||||||
|
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((await themeButton.count()) > 0) {
|
||||||
|
await test.step("toggle theme", async () => {
|
||||||
|
await themeButton.first().click()
|
||||||
|
await sleep(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify theme changed", async () => {
|
||||||
|
const newClass = await html.getAttribute("class")
|
||||||
|
expect(newClass).not.toBe(initialClass)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("theme persists after page reload", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
const themeButton = page.locator(
|
||||||
|
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((await themeButton.count()) > 0) {
|
||||||
|
let themeClass: string | null
|
||||||
|
|
||||||
|
await test.step("change theme", async () => {
|
||||||
|
await themeButton.first().click()
|
||||||
|
await sleep(300)
|
||||||
|
themeClass = await page.locator("html").getAttribute("class")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("reload page", async () => {
|
||||||
|
await page.reload({ waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step("verify theme persisted", async () => {
|
||||||
|
const reloadedClass = await page
|
||||||
|
.locator("html")
|
||||||
|
.getAttribute("class")
|
||||||
|
expect(reloadedClass).toBe(themeClass)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("draw.io theme toggle exists", async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
await openSettings(page)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="dialog"], [role="menu"], form').first(),
|
||||||
|
).toBeVisible({ timeout: 5000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("system theme preference is respected", async ({ page }) => {
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" })
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
|
||||||
|
const html = page.locator("html")
|
||||||
|
const classes = await html.getAttribute("class")
|
||||||
|
expect(classes).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
20
tests/e2e/upload.spec.ts
Normal file
20
tests/e2e/upload.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { expect, getIframe, test } from "./lib/fixtures"
|
||||||
|
|
||||||
|
test.describe("File Upload Area", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/", { waitUntil: "networkidle" })
|
||||||
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("page loads without console errors", async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on("pageerror", (err) => errors.push(err.message))
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const criticalErrors = errors.filter(
|
||||||
|
(e) => !e.includes("ResizeObserver") && !e.includes("Script error"),
|
||||||
|
)
|
||||||
|
expect(criticalErrors).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
50
tests/unit/ai-providers.test.ts
Normal file
50
tests/unit/ai-providers.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
|
||||||
|
|
||||||
|
describe("supportsPromptCaching", () => {
|
||||||
|
it("returns true for Claude models", () => {
|
||||||
|
expect(supportsPromptCaching("claude-sonnet-4-5")).toBe(true)
|
||||||
|
expect(supportsPromptCaching("anthropic.claude-3-5-sonnet")).toBe(true)
|
||||||
|
expect(supportsPromptCaching("us.anthropic.claude-3-5-sonnet")).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(supportsPromptCaching("eu.anthropic.claude-3-5-sonnet")).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for non-Claude models", () => {
|
||||||
|
expect(supportsPromptCaching("gpt-4o")).toBe(false)
|
||||||
|
expect(supportsPromptCaching("gemini-pro")).toBe(false)
|
||||||
|
expect(supportsPromptCaching("deepseek-chat")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("supportsImageInput", () => {
|
||||||
|
it("returns true for models with vision capability", () => {
|
||||||
|
expect(supportsImageInput("gpt-4-vision")).toBe(true)
|
||||||
|
expect(supportsImageInput("qwen-vl")).toBe(true)
|
||||||
|
expect(supportsImageInput("deepseek-vl")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for Kimi K2 models without vision", () => {
|
||||||
|
expect(supportsImageInput("kimi-k2")).toBe(false)
|
||||||
|
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for DeepSeek text models", () => {
|
||||||
|
expect(supportsImageInput("deepseek-chat")).toBe(false)
|
||||||
|
expect(supportsImageInput("deepseek-coder")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for Qwen text models", () => {
|
||||||
|
expect(supportsImageInput("qwen-turbo")).toBe(false)
|
||||||
|
expect(supportsImageInput("qwen-plus")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for Claude and GPT models by default", () => {
|
||||||
|
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
|
||||||
|
expect(supportsImageInput("gpt-4o")).toBe(true)
|
||||||
|
expect(supportsImageInput("gemini-pro")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
54
tests/unit/cached-responses.test.ts
Normal file
54
tests/unit/cached-responses.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
CACHED_EXAMPLE_RESPONSES,
|
||||||
|
findCachedResponse,
|
||||||
|
} from "@/lib/cached-responses"
|
||||||
|
|
||||||
|
describe("findCachedResponse", () => {
|
||||||
|
it("returns cached response for exact match without image", () => {
|
||||||
|
const result = findCachedResponse(
|
||||||
|
"Give me a **animated connector** diagram of transformer's architecture",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result?.xml).toContain("Transformer Architecture")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns cached response for exact match with image", () => {
|
||||||
|
const result = findCachedResponse("Replicate this in aws style", true)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result?.xml).toContain("AWS")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for non-matching prompt", () => {
|
||||||
|
const result = findCachedResponse(
|
||||||
|
"random prompt that doesn't exist",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when hasImage doesn't match", () => {
|
||||||
|
// This prompt exists but requires hasImage=true
|
||||||
|
const result = findCachedResponse("Replicate this in aws style", false)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined for partial match", () => {
|
||||||
|
const result = findCachedResponse("Give me a diagram", false)
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns response for Draw a cat prompt", () => {
|
||||||
|
const result = findCachedResponse("Draw a cat for me", false)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result?.xml).toContain("ellipse")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("all cached responses have non-empty xml", () => {
|
||||||
|
for (const response of CACHED_EXAMPLE_RESPONSES) {
|
||||||
|
expect(response.xml).not.toBe("")
|
||||||
|
expect(response.xml.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
171
tests/unit/chat-helpers.test.ts
Normal file
171
tests/unit/chat-helpers.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import {
|
||||||
|
isMinimalDiagram,
|
||||||
|
replaceHistoricalToolInputs,
|
||||||
|
validateFileParts,
|
||||||
|
} from "@/lib/chat-helpers"
|
||||||
|
|
||||||
|
describe("validateFileParts", () => {
|
||||||
|
it("returns valid for no files", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "user", parts: [{ type: "text", text: "hello" }] },
|
||||||
|
]
|
||||||
|
expect(validateFileParts(messages)).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns valid for files under limit", () => {
|
||||||
|
const smallBase64 = btoa("x".repeat(100))
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
url: `data:image/png;base64,${smallBase64}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(validateFileParts(messages)).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns error for too many files", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: Array(6)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => ({
|
||||||
|
type: "file",
|
||||||
|
url: "data:image/png;base64,abc",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = validateFileParts(messages)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("Too many files")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns error for file exceeding size limit", () => {
|
||||||
|
// Create base64 that decodes to > 2MB
|
||||||
|
const largeBase64 = btoa("x".repeat(3 * 1024 * 1024))
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
url: `data:image/png;base64,${largeBase64}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = validateFileParts(messages)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error).toContain("exceeds")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isMinimalDiagram", () => {
|
||||||
|
it("returns true for empty diagram", () => {
|
||||||
|
const xml = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
||||||
|
expect(isMinimalDiagram(xml)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for diagram with content", () => {
|
||||||
|
const xml =
|
||||||
|
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="Hello"/>'
|
||||||
|
expect(isMinimalDiagram(xml)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles whitespace correctly", () => {
|
||||||
|
const xml = ' <mxCell id="0"/> <mxCell id="1" parent="0"/> '
|
||||||
|
expect(isMinimalDiagram(xml)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("replaceHistoricalToolInputs", () => {
|
||||||
|
it("replaces display_diagram tool inputs with placeholder", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolName: "display_diagram",
|
||||||
|
input: { xml: "<mxCell...>" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = replaceHistoricalToolInputs(messages)
|
||||||
|
expect(result[0].content[0].input.placeholder).toContain(
|
||||||
|
"XML content replaced",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("replaces edit_diagram tool inputs with placeholder", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolName: "edit_diagram",
|
||||||
|
input: { operations: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = replaceHistoricalToolInputs(messages)
|
||||||
|
expect(result[0].content[0].input.placeholder).toContain(
|
||||||
|
"XML content replaced",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes tool calls with invalid inputs", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolName: "display_diagram",
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolName: "display_diagram",
|
||||||
|
input: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = replaceHistoricalToolInputs(messages)
|
||||||
|
expect(result[0].content).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves non-assistant messages", () => {
|
||||||
|
const messages = [{ role: "user", content: "hello" }]
|
||||||
|
const result = replaceHistoricalToolInputs(messages)
|
||||||
|
expect(result).toEqual(messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves other tool calls", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolName: "other_tool",
|
||||||
|
input: { foo: "bar" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const result = replaceHistoricalToolInputs(messages)
|
||||||
|
expect(result[0].content[0].input).toEqual({ foo: "bar" })
|
||||||
|
})
|
||||||
|
})
|
||||||
86
tests/unit/utils.test.ts
Normal file
86
tests/unit/utils.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||||
|
|
||||||
|
describe("isMxCellXmlComplete", () => {
|
||||||
|
it("returns false for empty/null input", () => {
|
||||||
|
expect(isMxCellXmlComplete("")).toBe(false)
|
||||||
|
expect(isMxCellXmlComplete(null)).toBe(false)
|
||||||
|
expect(isMxCellXmlComplete(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for self-closing mxCell", () => {
|
||||||
|
const xml =
|
||||||
|
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
|
||||||
|
expect(isMxCellXmlComplete(xml)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for mxCell with closing tag", () => {
|
||||||
|
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||||
|
</mxCell>`
|
||||||
|
expect(isMxCellXmlComplete(xml)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for truncated mxCell", () => {
|
||||||
|
const xml =
|
||||||
|
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
|
||||||
|
expect(isMxCellXmlComplete(xml)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for mxCell with unclosed geometry", () => {
|
||||||
|
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="100" y="100" width="120"`
|
||||||
|
expect(isMxCellXmlComplete(xml)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true for multiple complete mxCells", () => {
|
||||||
|
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
|
||||||
|
<mxCell id="3" value="B" vertex="1" parent="1"/>`
|
||||||
|
expect(isMxCellXmlComplete(xml)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("wrapWithMxFile", () => {
|
||||||
|
it("wraps empty string with default structure", () => {
|
||||||
|
const result = wrapWithMxFile("")
|
||||||
|
expect(result).toContain("<mxfile>")
|
||||||
|
expect(result).toContain("<mxGraphModel>")
|
||||||
|
expect(result).toContain('<mxCell id="0"/>')
|
||||||
|
expect(result).toContain('<mxCell id="1" parent="0"/>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("wraps raw mxCell content", () => {
|
||||||
|
const xml = '<mxCell id="2" value="Hello"/>'
|
||||||
|
const result = wrapWithMxFile(xml)
|
||||||
|
expect(result).toContain("<mxfile>")
|
||||||
|
expect(result).toContain(xml)
|
||||||
|
expect(result).toContain("</mxfile>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns full mxfile unchanged", () => {
|
||||||
|
const fullXml =
|
||||||
|
'<mxfile><diagram name="Page-1"><mxGraphModel></mxGraphModel></diagram></mxfile>'
|
||||||
|
const result = wrapWithMxFile(fullXml)
|
||||||
|
expect(result).toBe(fullXml)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles whitespace in input", () => {
|
||||||
|
const result = wrapWithMxFile(" ")
|
||||||
|
expect(result).toContain("<mxfile>")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cn (class name utility)", () => {
|
||||||
|
it("merges class names", () => {
|
||||||
|
expect(cn("foo", "bar")).toBe("foo bar")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles conditional classes", () => {
|
||||||
|
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("merges tailwind classes correctly", () => {
|
||||||
|
expect(cn("px-2", "px-4")).toBe("px-4")
|
||||||
|
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500")
|
||||||
|
})
|
||||||
|
})
|
||||||
17
vitest.config.mts
Normal file
17
vitest.config.mts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths"
|
||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths(), react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["tests/**/*.test.{ts,tsx}"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"],
|
||||||
|
exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user