Compare commits

..

7 Commits

Author SHA1 Message Date
yrk111222
54fd48506d Feat/add modelscope support (#521)
* add ModelScope API support

* update some documentation

* modify some details
2026-01-06 19:41:25 +09:00
zhoujie0531
ffcb241383 feat: mod readme (#522)
Co-authored-by: zoejiezhou <zoejiezhou@tencent.com>
2026-01-06 17:57:40 +09:00
Dayuan Jiang
79491e2143 chore: remove usage limits from about pages (#520) 2026-01-06 10:46:13 +09:00
Biki Kalita
6326f9dec6 🔗 Add URL Content Extraction Feature (#514)
* feat: add URL content extraction for AI diagram generation

* Changes made as recommended by Claude:

1. Added a request timeout to prevent server resources from being tied up (route.ts)
2. Implemented runtime validation for the API response shape (url-utils.ts)
3. Removed hardcoded English error messages and replaced them with localized strings (url-input-dialog.tsx)
4. Fixed the incorrect i18n namespace (changed from pdf.* to url.*) (url-input-dialog.tsx and en/ja/zh.json)

* chore: restore package.json and package-lock.json

* fix: use i18n strings for URL dialog error messages

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-06 00:23:50 +09:00
Dayuan Jiang
625d8f2afe fix: use OpenAI provider for Doubao multimodal models (#519)
DeepSeek provider was not properly formatting image content for Doubao's
API. Now uses OpenAI provider for Doubao models (multimodal support),
while keeping DeepSeek provider for DeepSeek/Kimi models on the platform.
2026-01-05 23:09:09 +09:00
Dayuan Jiang
0026639ee8 fix: add NEXT_PUBLIC_BASE_PATH build arg for subdirectory deployment (#518)
Dockerfile was missing the ARG declaration to receive NEXT_PUBLIC_BASE_PATH
from docker-compose build args, causing subdirectory deployment to fail.

Fixes #478
2026-01-05 21:40:15 +09:00
Dayuan Jiang
c7a85d398f test: add Vitest and Playwright testing infrastructure (#512)
* test: add Vitest and Playwright testing infrastructure

- Add Vitest for unit tests (39 tests)
  - cached-responses.test.ts
  - ai-providers.test.ts
  - chat-helpers.test.ts
  - utils.test.ts
- Add Playwright for E2E tests (3 smoke tests)
  - Homepage load
  - Japanese locale
  - Settings dialog
- Add CI workflow (.github/workflows/test.yml)
- Add vitest.config.mts and playwright.config.ts
- Update .gitignore for test artifacts

* test: add more E2E tests for UI components

- Chat panel tests (interactive elements, iframe)
- Settings tests (dark mode, language, draw.io theme)
- Save dialog tests (buttons exist)
- History dialog tests
- Model config tests
- Keyboard interaction tests
- Upload area tests

Total: 15 E2E tests, all passing

* test: fix E2E test issues from review

Fixes based on Gemini and Codex review:
- Remove brittle nth(1) selector in keyboard tests
- Remove waitForTimeout(500) race condition
- Remove if(isVisible) silent skip patterns
- Add proper assertions instead of no-op checks
- Remove expect(count >= 0) that always passes
- Remove unused hasProviderUI variable

All 14 E2E tests and 39 unit tests pass.

* style: auto-format with Biome

* fix: resolve lint errors for CI

* test(e2e): add diagram generation tests with mocked AI responses

- Add tests for generate, edit, and append diagram operations
- Use SSE mocked responses matching AI SDK UI message stream format
- Generate mxCell XML directly in tests for deterministic assertions
- Tests verify tool card rendering and 'Complete' badge state

* test: add comprehensive E2E tests for all major features

- Error handling tests (API errors, rate limits, network timeout, truncated XML)
- Multi-turn conversation tests (sequential requests, history preservation)
- File upload tests (upload button, file preview, sending with message)
- Theme switching tests (dark mode toggle, persistence, system preference)
- Language switching tests (EN/JA/ZH, persistence, locale URLs)
- Iframe interaction tests (draw.io loading, toolbar, diagram rendering)
- Copy/paste tests (chat input, XML input, special characters)
- History restore tests (new chat, persistence, browser navigation)

* refactor: extract shared test helpers and improve error assertions

- Create tests/e2e/lib/helpers.ts with shared SSE mock functions
- Add proper error UI assertions to error-handling.spec.ts
- Remove waitForTimeout calls in favor of real assertions
- Update 6 test files to use shared helpers

* docs: add testing section to CONTRIBUTING.md

* fix: improve test infrastructure based on PR review

- Fix double build in CI: remove redundant build from playwright webServer
- Export chat helpers from shared module for proper unit testing
- Replace waitForTimeout with explicit waits in E2E tests
- Add data-testid attributes to settings and new chat buttons
- Add list reporter for CI to show failures in logs
- Add Playwright browser caching to speed up CI
- Add vitest coverage configuration
- Fix conditional test assertions to use test.skip() instead of silent pass
- Remove unused variables flagged by linter

* fix: improve E2E test assertions and remove silent skips

- Replace silent test.skip() with explicit conditional skips
- Add actual persistence assertion after page reload
- Use data-testid selector for new chat button test

* refactor: add shared fixtures and test.step() patterns

- Add tests/e2e/lib/fixtures.ts with shared test helpers
- Add tests/e2e/fixtures/diagrams.ts with XML test data
- Add expectBeforeAndAfterReload() helper for persistence tests
- Add test.step() for better test reporting in complex tests
- Consolidate mock helpers into fixtures module
- Reduce code duplication across 17 test files

* fix: make persistence tests more reliable

- Remove expectBeforeAndAfterReload from mocked API tests
- Add explicit test.step() for before/after reload checks
- Add retry config for flaky clipboard tests
- Add sleep after reload for language persistence test

* test: remove flaky XML paste test

* docs: run both unit and e2e tests before PR

* chore: add type check and unit test git hooks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 01:37:32 +09:00
68 changed files with 4840 additions and 299 deletions

View File

@@ -20,15 +20,42 @@ npm run lint # Check lint errors
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.
## 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
1. Create a feature branch
2. Make changes and ensure `npm run check` passes
3. Submit PR against `main` with a clear description
2. Make changes (pre-commit runs lint + type check automatically)
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

75
.github/workflows/test.yml vendored Normal file
View 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
View File

@@ -14,6 +14,8 @@ packages/*/dist
# testing
/coverage
/playwright-report/
/test-results/
# next.js
/.next/

View File

@@ -1 +1,2 @@
npx lint-staged
npx tsc --noEmit

4
.husky/pre-push Normal file
View 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

View File

@@ -30,6 +30,10 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
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)
RUN npm run build

View File

@@ -40,7 +40,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Installation](#installation)
- [Deployment](#deployment)
- [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)
- [Multi-Provider Support](#multi-provider-support)
- [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).
### Deploy on Vercel (Recommended)
### Deploy on Vercel
[![Deploy with Vercel](https://vercel.com/button)](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
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway

View File

@@ -10,18 +10,7 @@ export const metadata: Metadata = {
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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -108,42 +97,6 @@ export default function AboutCN() {
</p>
</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 */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -344,6 +297,7 @@ export default function AboutCN() {
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>{" "}

View File

@@ -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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -116,42 +105,6 @@ export default function AboutJA() {
</p>
</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 */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -359,6 +312,7 @@ export default function AboutJA() {
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>

View File

@@ -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() {
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 (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -118,42 +107,6 @@ export default function About() {
</p>
</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 */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -378,6 +331,7 @@ export default function About() {
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on

View File

@@ -18,6 +18,11 @@ import {
supportsPromptCaching,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
@@ -34,93 +39,6 @@ import { getUserIdFromRequest } from "@/lib/user-id"
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
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`

154
app/api/parse-url/route.ts Normal file
View 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 },
)
}
}

View File

@@ -251,16 +251,98 @@ export async function POST(req: Request) {
}
case "doubao": {
// ByteDance Doubao uses DeepSeek-compatible API
const doubao = createDeepSeek({
apiKey,
baseURL:
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
})
model = doubao(modelId)
// 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({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
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:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },

View File

@@ -134,6 +134,7 @@ export const ModelSelectorLogo = ({
}
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
{...props}
alt={`${provider} logo`}

View File

@@ -4,6 +4,7 @@ import {
Download,
History,
Image as ImageIcon,
Link,
Loader2,
Send,
} from "lucide-react"
@@ -18,11 +19,13 @@ import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
@@ -144,6 +147,8 @@ interface ChatInputProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string
error?: Error | null
@@ -163,6 +168,8 @@ export function ChatInput({
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
@@ -183,6 +190,8 @@ export function ChatInput({
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showHistory, setShowHistory] = 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")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
@@ -312,6 +321,44 @@ export function ChatInput({
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
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) {
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
@@ -324,13 +371,23 @@ export function ChatInput({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File previews */}
{files.length > 0 && (
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/>
</div>
)}
@@ -385,6 +442,20 @@ export function ChatInput({
<ImageIcon className="h-4 w-4" />
</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
type="file"
ref={fileInputRef}
@@ -443,6 +514,14 @@ export function ChatInput({
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
}

View File

@@ -38,7 +38,6 @@ import {
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
// Helper to extract complete operations from streaming input
function getCompleteOperations(

View File

@@ -34,6 +34,7 @@ import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/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 { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
@@ -158,6 +159,7 @@ export default function ChatPanel({
// File processing using extracted hook
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor()
const [urlData, setUrlData] = useState<Map<string, UrlData>>(new Map())
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [showModelConfigDialog, setShowModelConfigDialog] = useState(false)
@@ -710,6 +712,8 @@ export default function ChatPanel({
input,
files,
pdfData,
undefined,
urlData,
)
setMessages([
@@ -735,6 +739,7 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
return
}
}
@@ -755,6 +760,7 @@ export default function ChatPanel({
files,
pdfData,
parts,
urlData,
)
// Add the combined text as the first part
@@ -779,6 +785,7 @@ export default function ChatPanel({
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
} catch (error) {
console.error("Error fetching chart data:", error)
}
@@ -854,6 +861,7 @@ export default function ChatPanel({
clearDiagram()
setDiagramHistory([])
handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
@@ -972,6 +980,7 @@ export default function ChatPanel({
files: File[],
pdfData: Map<File, FileData>,
imageParts?: any[],
urlDataParam?: Map<string, UrlData>,
): Promise<string> => {
let userText = baseText
@@ -1002,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
}
@@ -1185,6 +1202,7 @@ export default function ChatPanel({
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="new-chat-button"
>
<MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1197,6 +1215,7 @@ export default function ChatPanel({
size="icon"
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
data-testid="settings-button"
>
<Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1262,6 +1281,8 @@ export default function ChatPanel({
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
urlData={urlData}
onUrlChange={setUrlData}
sessionId={sessionId}
error={error}
models={modelConfig.models}

View File

@@ -1,6 +1,6 @@
"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 { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
@@ -20,12 +20,19 @@ interface FilePreviewListProps {
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<
string,
{ url: string; title: string; charCount: number; isExtracting: boolean }
>
onRemoveUrl?: (url: string) => void
}
export function FilePreviewList({
files,
onRemoveFile,
pdfData = new Map(),
urlData,
onRemoveUrl,
}: FilePreviewListProps) {
const dict = useDictionary()
const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -77,7 +84,7 @@ export function FilePreviewList({
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null
if (files.length === 0 && (!urlData || urlData.size === 0)) return null
return (
<>
@@ -152,6 +159,59 @@ export function FilePreviewList({
</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>
{/* Image Modal/Lightbox */}
{selectedImage && (

View File

@@ -79,6 +79,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
modelscope: "modelscope",
}
// Provider logo component
@@ -102,6 +103,7 @@ function ProviderLogo({
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
@@ -273,7 +275,7 @@ export function ModelConfigDialog({
// Validate all models
const handleValidate = useCallback(async () => {
if (!selectedProvider) return
if (!selectedProvider || !selectedProviderId) return
// Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock"
@@ -331,14 +333,14 @@ export function ModelConfigDialog({
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
})
} else {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
@@ -346,7 +348,7 @@ export function ModelConfigDialog({
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
})
@@ -357,7 +359,7 @@ export function ModelConfigDialog({
if (allValid) {
setValidationStatus("success")
updateProvider(selectedProviderId!, { validated: true })
updateProvider(selectedProviderId, { validated: true })
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
@@ -1298,20 +1300,24 @@ export function ModelConfigDialog({
null,
)
}
updateModel(
selectedProviderId!,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
}}
onKeyDown={(
e,

View File

@@ -50,6 +50,7 @@ const PROVIDER_LOGO_MAP: Record<string, string> = {
gateway: "vercel",
edgeone: "tencent-cloud",
doubao: "bytedance",
modelscope: "modelscope",
}
// Group models by providerLabel (handles duplicate providers)

View 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>
)
}

View File

@@ -37,7 +37,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [安装](#安装)
- [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages)
- [部署到Vercel(推荐)](#部署到vercel推荐)
- [部署到Vercel](#部署到vercel)
- [部署到Cloudflare Workers](#部署到cloudflare-workers)
- [多提供商支持](#多提供商支持)
- [工作原理](#工作原理)
@@ -179,7 +179,7 @@ npm run dev
同时通过腾讯云EdgeOne Pages部署也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。
### 部署到Vercel(推荐)
### 部署到Vercel
[![Deploy with Vercel](https://vercel.com/button)](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
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway

View File

@@ -152,6 +152,19 @@ AI_PROVIDER=ollama
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
```bash

View File

@@ -158,6 +158,19 @@ Optional custom URL:
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 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`:
```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

View File

@@ -37,7 +37,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [インストール](#インストール)
- [デプロイ](#デプロイ)
- [EdgeOne Pagesへのデプロイ](#edgeone-pagesへのデプロイ)
- [Vercelへのデプロイ(推奨)](#vercelへのデプロイ推奨)
- [Vercelへのデプロイ](#vercelへのデプロイ)
- [Cloudflare Workersへのデプロイ](#cloudflare-workersへのデプロイ)
- [マルチプロバイダーサポート](#マルチプロバイダーサポート)
- [仕組み](#仕組み)
@@ -180,7 +180,7 @@ npm run dev
また、Tencent EdgeOne Pagesでデプロイすると、[DeepSeekモデルの毎日の無料クォータ](https://pages.edgeone.ai/document/edge-ai)が付与されます。
### Vercelへのデプロイ(推奨)
### Vercelへのデプロイ
[![Deploy with Vercel](https://vercel.com/button)](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
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway

View File

@@ -158,6 +158,19 @@ AI_MODEL=llama3.2
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 は、単一の API キーで複数の AI プロバイダーへの統合アクセスを提供します。これにより認証が簡素化され、複数の API キーを管理することなくプロバイダーを切り替えることができます。

View File

@@ -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") {
return handleOptionsRequest()
}

View File

@@ -351,6 +351,10 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL",
},
modelscope: {
apiKey: "MODELSCOPE_API_KEY",
baseUrl: "MODELSCOPE_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: { apiKey: "", baseUrl: "" },

View File

@@ -55,6 +55,7 @@
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>

View File

@@ -288,6 +288,7 @@ function getProviderLabel(provider) {
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama",
}
return labels[provider] || provider

View File

@@ -72,6 +72,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# SGLANG_API_KEY=your-sglang-api-key
# 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)
# DOUBAO_API_KEY=your-doubao-api-key
# DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # ByteDance Volcengine endpoint

View File

@@ -23,6 +23,7 @@ export type ProviderName =
| "gateway"
| "edgeone"
| "doubao"
| "modelscope"
interface ModelConfig {
model: any
@@ -59,6 +60,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"gateway",
"edgeone",
"doubao",
"modelscope",
]
// Bedrock provider options for Anthropic beta features
@@ -353,6 +355,7 @@ function buildProviderOptions(
case "siliconflow":
case "sglang":
case "gateway":
case "modelscope":
case "doubao": {
// These providers don't have reasoning configs in AI SDK yet
// 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",
edgeone: null, // No credentials needed - uses EdgeOne Edge AI
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
*
* 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
*
* 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)
* - SGLANG_API_KEY: SGLang API key
* - SGLANG_BASE_URL: SGLang endpoint (optional)
* - MODELSCOPE_API_KEY: ModelScope API key
* - MODELSCOPE_BASE_URL: ModelScope endpoint (optional)
*/
export function getAIModel(overrides?: ClientOverrides): ModelConfig {
// SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm)
@@ -537,6 +543,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` +
`- MODELSCOPE_API_KEY for ModelScope\n` +
`Or set AI_PROVIDER=ollama for local Ollama.`,
)
} else {
@@ -573,8 +580,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
const bedrockProvider = hasClientCredentials
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey!,
accessKeyId: overrides.awsAccessKeyId as string,
secretAccessKey: overrides.awsSecretAccessKey as string,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
@@ -871,17 +878,44 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
overrides?.baseUrl ||
process.env.DOUBAO_BASE_URL ||
"https://ark.cn-beijing.volces.com/api/v3"
const doubaoProvider = createDeepSeek({
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({
apiKey,
baseURL,
})
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 = doubaoProvider(modelId)
model = modelscopeProvider.chat(modelId)
break
}
default:
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
View 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 }
})
}

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
@@ -51,7 +52,8 @@
"badResponse": "Bad response",
"clickToEdit": "Click to edit",
"editMessage": "Edit message",
"saveAndSubmit": "Save & Submit"
"saveAndSubmit": "Save & Submit",
"ExtractURL": "Extract from URL"
},
"examples": {
"title": "Create diagrams with AI",
@@ -186,6 +188,15 @@
"chars": "chars",
"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": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -51,7 +52,8 @@
"badResponse": "悪い応答",
"clickToEdit": "クリックして編集",
"editMessage": "メッセージを編集",
"saveAndSubmit": "保存して送信"
"saveAndSubmit": "保存して送信",
"ExtractURL": "URLから抽出"
},
"examples": {
"title": "AI でダイアグラムを作成",
@@ -186,6 +188,15 @@
"chars": "文字",
"removeFile": "ファイルを削除"
},
"url": {
"title": "URLからコンテンツを抽出",
"description": "URLを貼り付けてそのコンテンツを抽出および分析します",
"Extracting": "抽出中...",
"extract": "抽出",
"Cancel": "キャンセル",
"enterUrl": "URLを入力してください",
"invalidFormat": "無効なURL形式です"
},
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",

View File

@@ -28,7 +28,8 @@
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow"
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
@@ -51,7 +52,8 @@
"badResponse": "无帮助",
"clickToEdit": "点击编辑",
"editMessage": "编辑消息",
"saveAndSubmit": "保存并提交"
"saveAndSubmit": "保存并提交",
"ExtractURL": "从 URL 提取"
},
"examples": {
"title": "用 AI 创建图表",
@@ -186,6 +188,15 @@
"chars": "字符",
"removeFile": "移除文件"
},
"url": {
"title": "从 URL 提取内容",
"description": "粘贴 URL 以提取和分析其内容",
"Extracting": "提取中...",
"extract": "提取",
"Cancel": "取消",
"enterUrl": "请输入 URL",
"invalidFormat": "URL 格式无效"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",

View File

@@ -13,6 +13,7 @@ export type ProviderName =
| "gateway"
| "edgeone"
| "doubao"
| "modelscope"
// Individual model configuration
export interface ModelConfig {
@@ -91,6 +92,10 @@ export const PROVIDER_INFO: Record<
label: "Doubao (ByteDance)",
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
@@ -231,6 +236,17 @@ export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
"doubao-pro-32k-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

49
lib/url-utils.ts Normal file
View 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,
}
}

1720
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,9 @@
"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: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": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -38,6 +40,7 @@
"@ai-sdk/react": "^3.0.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.7.2",
"@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4",
@@ -66,7 +69,6 @@
"cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0",
"motion": "^12.23.25",
@@ -86,6 +88,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.0",
"unpdf": "^1.4.0",
"zod": "^4.1.12"
},
@@ -103,13 +106,20 @@
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@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/node": "^24.0.0",
"@types/pako": "^2.0.3",
"@types/react": "^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",
"cross-env": "^10.1.0",
"electron": "^39.2.7",
@@ -118,10 +128,13 @@
"eslint": "9.39.2",
"eslint-config-next": "16.1.1",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16",
"wait-on": "^9.0.3",
"wrangler": "4.54.0"
},

View File

@@ -1,18 +1,18 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.11",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.1.11",
"version": "0.1.6",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"zod": "^4.0.0"
"zod": "^3.24.0"
},
"bin": {
"next-ai-drawio-mcp": "dist/index.js"
@@ -2051,9 +2051,9 @@
}
},
"node_modules/zod": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {

View File

@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"zod": "^4.0.0"
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^24.0.0",

28
playwright.config.ts Normal file
View 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
View 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()
})
})

View 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)
})
})

View 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)
})
})

View 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,
})
})
})

View 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 })
})
})

View 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>`

View 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
View 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)
})
})

View 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)
}
})
})

View 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
View 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
View 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
View 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"
)
}

View 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)
})
})

View 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
View 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)
})
})

View 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
View 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
View 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
View 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([])
})
})

View 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)
})
})

View 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)
}
})
})

View 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
View 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
View 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"],
},
},
})