chore: add Biome for formatting and linting (#116)

- Add Biome as formatter and linter (replaces Prettier)
- Configure Husky + lint-staged for pre-commit hooks
- Add VS Code settings for format on save
- Ignore components/ui/ (shadcn generated code)
- Remove semicolons, use 4-space indent
- Reformat all files to new style
This commit is contained in:
Dayuan Jiang
2025-12-06 12:46:40 +09:00
committed by GitHub
parent 215a101f54
commit 150eb1ff63
41 changed files with 3992 additions and 2401 deletions

View File

@@ -1,6 +1,3 @@
{ {
"extends": [ "extends": ["next/core-web-vitals", "next/typescript"]
"next/core-web-vitals",
"next/typescript"
]
} }

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

23
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -1,13 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "关于 - Next AI Draw.io", title: "关于 - Next AI Draw.io",
description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。", description:
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"], keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
}; }
export default function AboutCN() { export default function AboutCN() {
return ( return (
@@ -16,14 +17,23 @@ export default function AboutCN() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700"> <Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors"> <Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link> </Link>
<Link href="/about/cn" className="text-blue-600 font-semibold"> <Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -45,22 +55,41 @@ export default function AboutCN() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1> <h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 - AI驱动的图表创建工具 -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <div className="flex justify-center gap-4 mt-4 text-sm">
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link> <Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/cn" className="text-blue-600 font-semibold"></Link> <Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600"></Link> <Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div> </div>
</div> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
Claude Opus 4.5 Claude Haiku 4.5 Claude Opus 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -69,80 +98,167 @@ export default function AboutCN() {
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>LLM驱动的图表创建</strong>draw.io图表</li> <li>
<li><strong></strong>AI自动复制和增强</li> <strong>LLM驱动的图表创建</strong>
<li><strong></strong>AI编辑前的图表版本</li> draw.io图表
<li><strong></strong>AI实时对话来完善您的图表</li> </li>
<li><strong>AWS架构图支持</strong>AWS架构图</li> <li>
<li><strong></strong></li> <strong></strong>
AI自动复制和增强
</li>
<li>
<strong></strong>
AI编辑前的图表版本
</li>
<li>
<strong></strong>
AI实时对话来完善您的图表
</li>
<li>
<strong>AWS架构图支持</strong>
AWS架构图
</li>
<li>
<strong></strong>
</li>
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-6"></p>
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Transformer连接器</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformer连接器
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong></strong>Transformer架构图 <strong></strong>
<strong></strong>Transformer架构图
</p> </p>
<Image src="/animated_connectors.svg" alt="带动画连接器的Transformer架构" width={480} height={360} className="mx-auto" /> <Image
src="/animated_connectors.svg"
alt="带动画连接器的Transformer架构"
width={480}
height={360}
className="mx-auto"
/>
</div> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP架构图</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>GCP图标</strong>GCP架构图 <strong></strong> 使
<strong>GCP图标</strong>
GCP架构图
</p> </p>
<Image src="/gcp_demo.svg" alt="GCP架构图" width={400} height={300} className="mx-auto" /> <Image
src="/gcp_demo.svg"
alt="GCP架构图"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS架构图</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>AWS图标</strong>AWS架构图 <strong></strong> 使
<strong>AWS图标</strong>
AWS架构图
</p> </p>
<Image src="/aws_demo.svg" alt="AWS架构图" width={400} height={300} className="mx-auto" /> <Image
src="/aws_demo.svg"
alt="AWS架构图"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure架构图</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使<strong>Azure图标</strong>Azure架构图 <strong></strong> 使
<strong>Azure图标</strong>
Azure架构图
</p> </p>
<Image src="/azure_demo.svg" alt="Azure架构图" width={400} height={300} className="mx-auto" /> <Image
src="/azure_demo.svg"
alt="Azure架构图"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong></strong>{" "}
</p> </p>
<Image src="/cat_demo.svg" alt="猫咪绘图" width={240} height={240} className="mx-auto" /> <Image
src="/cat_demo.svg"
alt="猫咪绘图"
width={240}
height={240}
className="mx-auto"
/>
</div> </div>
</div> </div>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<p className="text-gray-700 mb-4">使</p> <p className="text-gray-700 mb-4">使</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>Next.js</strong></li> <li>
<li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>用于流式AI响应和多提供商支持</li> <strong>Next.js</strong>
<li><strong>react-drawio</strong>:用于图表表示和操作</li> </li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
用于流式AI响应和多提供商支持
</li>
<li>
<strong>react-drawio</strong>:用于图表表示和操作
</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。 图表以XML格式表示可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
</p> </p>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li> <li>AWS Bedrock</li>
<li>OpenAI / OpenAI兼容API <code>OPENAI_BASE_URL</code></li> <li>
OpenAI / OpenAI兼容API{" "}
<code>OPENAI_BASE_URL</code>
</li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -151,12 +267,15 @@ export default function AboutCN() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code> AWS标志的draw.io图表上进行训练AWS架构图 <code>claude-sonnet-4-5</code>{" "}
AWS标志的draw.io图表上进行训练AWS架构图
</p> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900"></h2> <h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -167,14 +286,24 @@ export default function AboutCN() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
{" "} {" "}
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>{" "} </a>{" "}
线 线
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
{" "} {" "}
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub仓库 GitHub仓库
</a>{" "} </a>{" "}
issue或联系me[at]jiang.jp issue或联系me[at]jiang.jp
@@ -201,5 +330,5 @@ export default function AboutCN() {
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,13 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "概要 - Next AI Draw.io", title: "概要 - Next AI Draw.io",
description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。", description:
keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"], "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
}; keywords: [
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
}
export default function AboutJA() { export default function AboutJA() {
return ( return (
@@ -16,14 +24,23 @@ export default function AboutJA() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700"> <Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors"> <Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link> </Link>
<Link href="/about/ja" className="text-blue-600 font-semibold"> <Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -45,22 +62,43 @@ export default function AboutJA() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1> <h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI搭載のダイアグラム作成ツール - AI搭載のダイアグラム作成ツール -
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <div className="flex justify-center gap-4 mt-4 text-sm">
<Link href="/about" className="text-gray-600 hover:text-blue-600">English</Link> <Link
href="/about"
className="text-gray-600 hover:text-blue-600"
>
English
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600"></Link> <Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/ja" className="text-blue-600 font-semibold"></Link> <Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link>
</div> </div>
</div> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
Claude Opus 4.5 Claude Haiku 4.5 Claude
Opus 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -69,80 +107,176 @@ export default function AboutJA() {
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>LLM搭載のダイアグラム作成</strong>draw.ioダイアグラムを作成</li> <li>
<li><strong></strong>AIが自動的に複製</li> <strong>LLM搭載のダイアグラム作成</strong>
<li><strong></strong>AI編集前のダイアグラムの以前のバージョンを表示</li> draw.ioダイアグラムを作成
<li><strong></strong>AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li> </li>
<li><strong>AWSアーキテクチャダイアグラムサポート</strong>AWSアーキテクチャダイアグラムの生成を専門的にサポート</li> <li>
<li><strong></strong></li> <strong></strong>
AIが自動的に複製
</li>
<li>
<strong></strong>
AI編集前のダイアグラムの以前のバージョンを表示
</li>
<li>
<strong>
</strong>
AIとリアルタイムでコミュニケーションしてダイアグラムを改善
</li>
<li>
<strong>
AWSアーキテクチャダイアグラムサポート
</strong>
AWSアーキテクチャダイアグラムの生成を専門的にサポート
</li>
<li>
<strong></strong>
</li>
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-6"></p>
</h2>
<p className="text-gray-700 mb-6">
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Transformerコネクタ</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Transformerコネクタ
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong></strong>Transformerアーキテクチャ図を作成してください <strong></strong>{" "}
<strong></strong>
Transformerアーキテクチャ図を作成してください
</p> </p>
<Image src="/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width={480} height={360} className="mx-auto" /> <Image
src="/animated_connectors.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
width={480}
height={360}
className="mx-auto"
/>
</div> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCPアーキテクチャ図</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>GCPアイコン</strong>使GCPアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>GCPアイコン</strong>
使GCPアーキテクチャ図を生成してください
</p> </p>
<Image src="/gcp_demo.svg" alt="GCPアーキテクチャ図" width={400} height={300} className="mx-auto" /> <Image
src="/gcp_demo.svg"
alt="GCPアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWSアーキテクチャ図</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
AWSアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>AWSアイコン</strong>使AWSアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>AWSアイコン</strong>
使AWSアーキテクチャ図を生成してください
</p> </p>
<Image src="/aws_demo.svg" alt="AWSアーキテクチャ図" width={400} height={300} className="mx-auto" /> <Image
src="/aws_demo.svg"
alt="AWSアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azureアーキテクチャ図</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Azureアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong>Azureアイコン</strong>使Azureアーキテクチャ図を生成してください <strong></strong>{" "}
<strong>Azureアイコン</strong>
使Azureアーキテクチャ図を生成してください
</p> </p>
<Image src="/azure_demo.svg" alt="Azureアーキテクチャ図" width={400} height={300} className="mx-auto" /> <Image
src="/azure_demo.svg"
alt="Azureアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> <strong></strong>{" "}
</p> </p>
<Image src="/cat_demo.svg" alt="猫の絵" width={240} height={240} className="mx-auto" /> <Image
src="/cat_demo.svg"
alt="猫の絵"
width={240}
height={240}
className="mx-auto"
/>
</div> </div>
</div> </div>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-4">使</p>
</h2>
<p className="text-gray-700 mb-4">
使
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>Next.js</strong></li> <li>
<li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>ストリーミングAIレスポンスとマルチプロバイダーサポート</li> <strong>Next.js</strong>
<li><strong>react-drawio</strong>:ダイアグラムの表現と操作</li>
</li>
<li>
<strong>Vercel AI SDK</strong><code>ai</code> +{" "}
<code>@ai-sdk/*</code>
ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>react-drawio</strong>
:ダイアグラムの表現と操作
</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。 ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
</p> </p>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock</li> <li>AWS Bedrock</li>
<li>OpenAI / OpenAI互換API<code>OPENAI_BASE_URL</code></li> <li>
OpenAI / OpenAI互換API<code>OPENAI_BASE_URL</code>
</li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -151,12 +285,15 @@ export default function AboutJA() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
<code>claude-sonnet-4-5</code>AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です <code>claude-sonnet-4-5</code>
AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です
</p> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900"></h2> <h2 className="text-2xl font-semibold text-gray-900">
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -167,14 +304,24 @@ export default function AboutJA() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
{" "} {" "}
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
</a>{" "} </a>{" "}
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
{" "} {" "}
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHubリポジトリ GitHubリポジトリ
</a>{" "} </a>{" "}
issueを開くかme[at]jiang.jp issueを開くかme[at]jiang.jp
@@ -196,10 +343,11 @@ export default function AboutJA() {
<footer className="bg-white border-t border-gray-200 mt-16"> <footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - AI搭載ダイアグラムジェネレーター Next AI Draw.io -
AI搭載ダイアグラムジェネレーター
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,13 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import Link from "next/link"; import Image from "next/image"
import { FaGithub } from "react-icons/fa"; import Link from "next/link"
import Image from "next/image"; import { FaGithub } from "react-icons/fa"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "About - Next AI Draw.io", title: "About - Next AI Draw.io",
description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.", description:
keywords: ["AI diagram", "draw.io", "AWS architecture", "GCP diagram", "Azure diagram", "LLM"], "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
}; keywords: [
"AI diagram",
"draw.io",
"AWS architecture",
"GCP diagram",
"Azure diagram",
"LLM",
],
}
export default function About() { export default function About() {
return ( return (
@@ -16,14 +24,23 @@ export default function About() {
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700"> <Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors"> <Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Editor Editor
</Link> </Link>
<Link href="/about" className="text-blue-600 font-semibold"> <Link
href="/about"
className="text-blue-600 font-semibold"
>
About About
</Link> </Link>
<a <a
@@ -45,105 +62,236 @@ export default function About() {
<article className="prose prose-lg max-w-none"> <article className="prose prose-lg max-w-none">
{/* Title */} {/* Title */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1> <h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI-Powered Diagram Creation Tool - Chat, Draw, Visualize AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize
</p> </p>
<div className="flex justify-center gap-4 mt-4 text-sm"> <div className="flex justify-center gap-4 mt-4 text-sm">
<Link href="/about" className="text-blue-600 font-semibold">English</Link> <Link
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/cn" className="text-gray-600 hover:text-blue-600"></Link> <Link
href="/about/cn"
className="text-gray-600 hover:text-blue-600"
>
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link href="/about/ja" className="text-gray-600 hover:text-blue-600"></Link> <Link
href="/about/ja"
className="text-gray-600 hover:text-blue-600"
>
</Link>
</div> </div>
</div> </div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-amber-800"> <p className="text-amber-800">
This app is designed to run on Claude Opus 4.5 for best performance. However, due to higher-than-expected traffic, running the top-tier model has become cost-prohibitive. To avoid service interruptions and manage costs, I have switched the backend to Claude Haiku 4.5. This app is designed to run on Claude Opus 4.5 for
best performance. However, due to
higher-than-expected traffic, running the top-tier
model has become cost-prohibitive. To avoid service
interruptions and manage costs, I have switched the
backend to Claude Haiku 4.5.
</p> </p>
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
A Next.js web application that integrates AI capabilities with draw.io diagrams. A Next.js web application that integrates AI
Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization. capabilities with draw.io diagrams. Create, modify, and
enhance diagrams through natural language commands and
AI-assisted visualization.
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Features</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Features
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>LLM-Powered Diagram Creation</strong>: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands</li> <li>
<li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li> <strong>LLM-Powered Diagram Creation</strong>:
<li><strong>Diagram History</strong>: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing</li> Leverage Large Language Models to create and
<li><strong>Interactive Chat Interface</strong>: Communicate with AI to refine your diagrams in real-time</li> manipulate draw.io diagrams directly through natural
<li><strong>AWS Architecture Diagram Support</strong>: Specialized support for generating AWS architecture diagrams</li> language commands
<li><strong>Animated Connectors</strong>: Create dynamic and animated connectors between diagram elements for better visualization</li> </li>
<li>
<strong>Image-Based Diagram Replication</strong>:
Upload existing diagrams or images and have the AI
replicate and enhance them automatically
</li>
<li>
<strong>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</li>
<li>
<strong>Interactive Chat Interface</strong>:
Communicate with AI to refine your diagrams in
real-time
</li>
<li>
<strong>AWS Architecture Diagram Support</strong>:
Specialized support for generating AWS architecture
diagrams
</li>
<li>
<strong>Animated Connectors</strong>: Create dynamic
and animated connectors between diagram elements for
better visualization
</li>
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p> Examples
</h2>
<p className="text-gray-700 mb-6">
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* Animated Transformer */} {/* Animated Transformer */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Animated Transformer Connectors</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated Transformer Connectors
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer&apos;s architecture. <strong>Prompt:</strong> Give me an{" "}
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
</p> </p>
<Image src="/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width={480} height={360} className="mx-auto" /> <Image
src="/animated_connectors.svg"
alt="Transformer Architecture with Animated Connectors"
width={480}
height={360}
className="mx-auto"
/>
</div> </div>
{/* Cloud Architecture Grid */} {/* Cloud Architecture Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">GCP Architecture Diagram</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a GCP architecture diagram with <strong>GCP icons</strong>. Users connect to a frontend hosted on an instance. <strong>Prompt:</strong> Generate a GCP
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
</p> </p>
<Image src="/gcp_demo.svg" alt="GCP Architecture Diagram" width={400} height={300} className="mx-auto" /> <Image
src="/gcp_demo.svg"
alt="GCP Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">AWS Architecture Diagram</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an AWS architecture diagram with <strong>AWS icons</strong>. Users connect to a frontend hosted on an instance. <strong>Prompt:</strong> Generate an AWS
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
</p> </p>
<Image src="/aws_demo.svg" alt="AWS Architecture Diagram" width={400} height={300} className="mx-auto" /> <Image
src="/aws_demo.svg"
alt="AWS Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Azure Architecture Diagram</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure Architecture Diagram
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an Azure architecture diagram with <strong>Azure icons</strong>. Users connect to a frontend hosted on an instance. <strong>Prompt:</strong> Generate an Azure
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
</p> </p>
<Image src="/azure_demo.svg" alt="Azure Architecture Diagram" width={400} height={300} className="mx-auto" /> <Image
src="/azure_demo.svg"
alt="Azure Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
</div> </div>
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Cat Sketch</h3> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Cat Sketch
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Draw a cute cat for me. <strong>Prompt:</strong> Draw a cute cat for
me.
</p> </p>
<Image src="/cat_demo.svg" alt="Cat Drawing" width={240} height={240} className="mx-auto" /> <Image
src="/cat_demo.svg"
alt="Cat Drawing"
width={240}
height={240}
className="mx-auto"
/>
</div> </div>
</div> </div>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
<p className="text-gray-700 mb-4">The application uses the following technologies:</p> How It Works
</h2>
<p className="text-gray-700 mb-4">
The application uses the following technologies:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li><strong>Next.js</strong>: For the frontend framework and routing</li> <li>
<li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li> <strong>Next.js</strong>: For the frontend framework
<li><strong>react-drawio</strong>: For diagram representation and manipulation</li> and routing
</li>
<li>
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
<code>@ai-sdk/*</code>): For streaming AI responses
and multi-provider support
</li>
<li>
<strong>react-drawio</strong>: For diagram
representation and manipulation
</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly. Diagrams are represented as XML that can be rendered in
draw.io. The AI processes your commands and generates or
modifies this XML accordingly.
</p> </p>
{/* Multi-Provider Support */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Multi-Provider Support</h2> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Multi-Provider Support
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>AWS Bedrock (default)</li> <li>AWS Bedrock (default)</li>
<li>OpenAI / OpenAI-compatible APIs (via <code>OPENAI_BASE_URL</code>)</li> <li>
OpenAI / OpenAI-compatible APIs (via{" "}
<code>OPENAI_BASE_URL</code>)
</li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
<li>Azure OpenAI</li> <li>Azure OpenAI</li>
@@ -152,12 +300,17 @@ export default function About() {
<li>DeepSeek</li> <li>DeepSeek</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice. Note that <code>claude-sonnet-4-5</code> has trained on
draw.io diagrams with AWS logos, so if you want to
create AWS architecture diagrams, this is the best
choice.
</p> </p>
{/* Support */} {/* Support */}
<div className="flex items-center gap-4 mt-10 mb-4"> <div className="flex items-center gap-4 mt-10 mb-4">
<h2 className="text-2xl font-semibold text-gray-900">Support &amp; Contact</h2> <h2 className="text-2xl font-semibold text-gray-900">
Support &amp; Contact
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -168,14 +321,24 @@ export default function About() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
If you find this project useful, please consider{" "} If you find this project useful, please consider{" "}
<a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
sponsoring sponsoring
</a>{" "} </a>{" "}
to help host the live demo site! to help host the live demo site!
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
For support or inquiries, please open an issue on the{" "} For support or inquiries, please open an issue on the{" "}
<a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub repository GitHub repository
</a>{" "} </a>{" "}
or contact: me[at]jiang.jp or contact: me[at]jiang.jp
@@ -197,10 +360,11 @@ export default function About() {
<footer className="bg-white border-t border-gray-200 mt-16"> <footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - Open Source AI-Powered Diagram Generator Next AI Draw.io - Open Source AI-Powered Diagram
Generator
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
); )
} }

View File

@@ -1,177 +1,223 @@
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'; import {
import { getAIModel } from '@/lib/ai-providers'; convertToModelMessages,
import { findCachedResponse } from '@/lib/cached-responses'; createUIMessageStream,
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse'; createUIMessageStreamResponse,
import { getSystemPrompt } from '@/lib/system-prompts'; streamText,
import { z } from "zod"; } from "ai"
import { z } from "zod"
import { getAIModel } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
getTelemetryConfig,
setTraceInput,
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts"
export const maxDuration = 300; export const maxDuration = 300
// File upload limits (must match client-side) // File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5; const MAX_FILES = 5
// Helper function to validate file parts in messages // Helper function to validate file parts in messages
function validateFileParts(messages: any[]): { valid: boolean; error?: string } { function validateFileParts(messages: any[]): {
const lastMessage = messages[messages.length - 1]; valid: boolean
const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || []; error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) { if (fileParts.length > MAX_FILES) {
return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` }; return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
} }
for (const filePart of fileParts) { for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data> // Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size // Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith('data:')) { if (filePart.url && filePart.url.startsWith("data:")) {
const base64Data = filePart.url.split(',')[1]; const base64Data = filePart.url.split(",")[1]
if (base64Data) { if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4); const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) { if (sizeInBytes > MAX_FILE_SIZE) {
return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` }; return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
} }
} }
} }
} }
return { valid: true }; return { valid: true }
} }
// Helper function to check if diagram is minimal/empty // Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean { function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, ''); const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"'); return !stripped.includes('id="2"')
} }
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`; const toolCallId = `cached-${Date.now()}`
const stream = createUIMessageStream({ const stream = createUIMessageStream({
execute: async ({ writer }) => { execute: async ({ writer }) => {
writer.write({ type: 'start' }); writer.write({ type: "start" })
writer.write({ type: 'tool-input-start', toolCallId, toolName: 'display_diagram' }); writer.write({
writer.write({ type: 'tool-input-delta', toolCallId, inputTextDelta: xml }); type: "tool-input-start",
writer.write({ type: 'tool-input-available', toolCallId, toolName: 'display_diagram', input: { xml } }); toolCallId,
writer.write({ type: 'finish' }); toolName: "display_diagram",
})
writer.write({
type: "tool-input-delta",
toolCallId,
inputTextDelta: xml,
})
writer.write({
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml },
})
writer.write({ type: "finish" })
}, },
}); })
return createUIMessageStreamResponse({ stream }); return createUIMessageStreamResponse({ stream })
} }
// Inner handler function // Inner handler function
async function handleChatRequest(req: Request): Promise<Response> { async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code // Check for access code
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || []; const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length > 0) { if (accessCodes.length > 0) {
const accessCodeHeader = req.headers.get('x-access-code'); const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) { if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
return Response.json( return Response.json(
{ error: 'Invalid or missing access code. Please configure it in Settings.' }, {
{ status: 401 } error: "Invalid or missing access code. Please configure it in Settings.",
); },
{ status: 401 },
)
} }
} }
const { messages, xml, sessionId } = await req.json(); const { messages, xml, sessionId } = await req.json()
// Get user IP for Langfuse tracking // Get user IP for Langfuse tracking
const forwardedFor = req.headers.get('x-forwarded-for'); const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
// Validate sessionId for Langfuse (must be string, max 200 chars) // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200 const validSessionId =
sessionId && typeof sessionId === "string" && sessionId.length <= 200
? sessionId ? sessionId
: undefined; : undefined
// Extract user input text for Langfuse trace // Extract user input text for Langfuse trace
const currentMessage = messages[messages.length - 1]; const currentMessage = messages[messages.length - 1]
const userInputText = currentMessage?.parts?.find((p: any) => p.type === 'text')?.text || ''; const userInputText =
currentMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
input: userInputText, input: userInputText,
sessionId: validSessionId, sessionId: validSessionId,
userId: userId, userId: userId,
}); })
// === FILE VALIDATION START === // === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages); const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) { if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 }); return Response.json({ error: fileValidation.error }, { status: 400 })
} }
// === FILE VALIDATION END === // === FILE VALIDATION END ===
// === CACHE CHECK START === // === CACHE CHECK START ===
const isFirstMessage = messages.length === 1; const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml); const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
if (isFirstMessage && isEmptyDiagram) { if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]; const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text'); const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file'); const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || '', !!filePart); const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) { if (cached) {
console.log('[Cache] Returning cached response for:', textPart?.text); console.log(
return createCachedStreamResponse(cached.xml); "[Cache] Returning cached response for:",
textPart?.text,
)
return createCachedStreamResponse(cached.xml)
} }
} }
// === CACHE CHECK END === // === CACHE CHECK END ===
// Get AI model from environment configuration // Get AI model from environment configuration
const { model, providerOptions, headers, modelId } = getAIModel(); const { model, providerOptions, headers, modelId } = getAIModel()
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId); const systemMessage = getSystemPrompt(modelId)
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1]
// Extract text from the last message parts // Extract text from the last message parts
const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || ''; const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
// Extract file parts (images) from the last message // Extract file parts (images) from the last message
const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || []; const fileParts =
lastMessage.parts?.filter((part: any) => part.type === "file") || []
// User input only - XML is now in a separate cached system message // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
"""md """md
${lastMessageText} ${lastMessageText}
"""`; """`
// Convert UIMessages to ModelMessages and add system message // Convert UIMessages to ModelMessages and add system message
const modelMessages = convertToModelMessages(messages); const modelMessages = convertToModelMessages(messages)
// Filter out messages with empty content arrays (Bedrock API rejects these) // Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases // This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = modelMessages.filter((msg: any) => let enhancedMessages = modelMessages.filter(
msg.content && Array.isArray(msg.content) && msg.content.length > 0 (msg: any) =>
); msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
// Update the last message with user input only (XML moved to separate cached system message) // Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) { if (enhancedMessages.length >= 1) {
const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]; const lastModelMessage = enhancedMessages[enhancedMessages.length - 1]
if (lastModelMessage.role === 'user') { if (lastModelMessage.role === "user") {
// Build content array with user input text and file parts // Build content array with user input text and file parts
const contentParts: any[] = [ const contentParts: any[] = [
{ type: 'text', text: formattedUserInput } { type: "text", text: formattedUserInput },
]; ]
// Add image parts back // Add image parts back
for (const filePart of fileParts) { for (const filePart of fileParts) {
contentParts.push({ contentParts.push({
type: 'image', type: "image",
image: filePart.url, image: filePart.url,
mimeType: filePart.mediaType mimeType: filePart.mediaType,
}); })
} }
enhancedMessages = [ enhancedMessages = [
...enhancedMessages.slice(0, -1), ...enhancedMessages.slice(0, -1),
{ ...lastModelMessage, content: contentParts } { ...lastModelMessage, content: contentParts },
]; ]
} }
} }
@@ -181,14 +227,14 @@ ${lastMessageText}
if (enhancedMessages.length >= 2) { if (enhancedMessages.length >= 2) {
// Find the last assistant message (should be second-to-last, before current user message) // Find the last assistant message (should be second-to-last, before current user message)
for (let i = enhancedMessages.length - 2; i >= 0; i--) { for (let i = enhancedMessages.length - 2; i >= 0; i--) {
if (enhancedMessages[i].role === 'assistant') { if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = { enhancedMessages[i] = {
...enhancedMessages[i], ...enhancedMessages[i],
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}; }
break; // Only cache the last assistant message break // Only cache the last assistant message
} }
} }
} }
@@ -201,23 +247,23 @@ ${lastMessageText}
const systemMessages = [ const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change) // Cache breakpoint 1: Instructions (rarely change)
{ {
role: 'system' as const, role: "system" as const,
content: systemMessage, content: systemMessage,
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}, },
// Cache breakpoint 2: Current diagram XML context // Cache breakpoint 2: Current diagram XML context
{ {
role: 'system' as const, role: "system" as const,
content: `Current diagram XML:\n"""xml\n${xml || ''}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`, content: `Current diagram XML:\n"""xml\n${xml || ""}\n"""\nWhen using edit_diagram, COPY search patterns exactly from this XML - attribute order matters!`,
providerOptions: { providerOptions: {
bedrock: { cachePoint: { type: 'default' } }, bedrock: { cachePoint: { type: "default" } },
}, },
}, },
]; ]
const allMessages = [...systemMessages, ...enhancedMessages]; const allMessages = [...systemMessages, ...enhancedMessages]
const result = streamText({ const result = streamText({
model, model,
@@ -226,17 +272,23 @@ ${lastMessageText}
...(headers && { headers }), ...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured) // Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && { ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }), experimental_telemetry: getTelemetryConfig({
sessionId: validSessionId,
userId,
}),
}), }),
onFinish: ({ text, usage, providerMetadata }) => { onFinish: ({ text, usage, providerMetadata }) => {
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2)); console.log(
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2)); "[Cache] Full providerMetadata:",
JSON.stringify(providerMetadata, null, 2),
)
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2))
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry) // Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens // AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
setTraceOutput(text, { setTraceOutput(text, {
promptTokens: usage?.inputTokens, promptTokens: usage?.inputTokens,
completionTokens: usage?.outputTokens, completionTokens: usage?.outputTokens,
}); })
}, },
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
@@ -277,8 +329,10 @@ Notes:
- For animated connectors, add "flowAnimation=1" to edge style. - For animated connectors, add "flowAnimation=1" to edge style.
`, `,
inputSchema: z.object({ inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io") xml: z
}) .string()
.describe("XML string to be displayed on draw.io"),
}),
}, },
edit_diagram: { edit_diagram: {
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML. description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
@@ -290,32 +344,47 @@ IMPORTANT: Keep edits concise:
- Each search must contain complete lines (never truncate mid-line) - Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element`, - First match only - be specific enough to target the right element`,
inputSchema: z.object({ inputSchema: z.object({
edits: z.array(z.object({ edits: z
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"), .array(
replace: z.string().describe("Replacement lines") z.object({
})).describe("Array of search/replace pairs to apply sequentially") search: z
}) .string()
.describe(
"EXACT lines copied from current XML (preserve attribute order!)",
),
replace: z
.string()
.describe("Replacement lines"),
}),
)
.describe(
"Array of search/replace pairs to apply sequentially",
),
}),
}, },
}, },
temperature: 0, temperature: 0,
}); })
return result.toUIMessageStreamResponse(); return result.toUIMessageStreamResponse()
} }
// Wrap handler with error handling // Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> { async function safeHandler(req: Request): Promise<Response> {
try { try {
return await handleChatRequest(req); return await handleChatRequest(req)
} catch (error) { } catch (error) {
console.error('Error in chat route:', error); console.error("Error in chat route:", error)
return Response.json({ error: 'Internal server error' }, { status: 500 }); return Response.json(
{ error: "Internal server error" },
{ status: 500 },
)
} }
} }
// Wrap with Langfuse observe (if configured) // Wrap with Langfuse observe (if configured)
const observedHandler = wrapWithObserve(safeHandler); const observedHandler = wrapWithObserve(safeHandler)
export async function POST(req: Request) { export async function POST(req: Request) {
return observedHandler(req); return observedHandler(req)
} }

View File

@@ -1,9 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server"
export async function GET() { export async function GET() {
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || []; const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
return NextResponse.json({ return NextResponse.json({
accessCodeRequired: accessCodes.length > 0, accessCodeRequired: accessCodes.length > 0,
}); })
} }

View File

@@ -1,103 +1,112 @@
import { getLangfuseClient } from '@/lib/langfuse'; import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'; import { z } from "zod"
import { z } from 'zod'; import { getLangfuseClient } from "@/lib/langfuse"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
messageId: z.string().min(1).max(200), messageId: z.string().min(1).max(200),
feedback: z.enum(['good', 'bad']), feedback: z.enum(["good", "bad"]),
sessionId: z.string().min(1).max(200).optional(), sessionId: z.string().min(1).max(200).optional(),
}); })
export async function POST(req: Request) { export async function POST(req: Request) {
const langfuse = getLangfuseClient(); const langfuse = getLangfuseClient()
if (!langfuse) { if (!langfuse) {
return Response.json({ success: true, logged: false }); return Response.json({ success: true, logged: false })
} }
// Validate input // Validate input
let data; let data
try { try {
data = feedbackSchema.parse(await req.json()); data = feedbackSchema.parse(await req.json())
} catch { } catch {
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 }); return Response.json(
{ success: false, error: "Invalid input" },
{ status: 400 },
)
} }
const { messageId, feedback, sessionId } = data; const { messageId, feedback, sessionId } = data
// Get user IP for tracking // Get user IP for tracking
const forwardedFor = req.headers.get('x-forwarded-for'); const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous'; const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
try { try {
// Find the most recent chat trace for this session to attach the score to // Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({ const tracesResponse = await langfuse.api.trace.list({
sessionId, sessionId,
limit: 1, limit: 1,
}); })
const traces = tracesResponse.data || []; const traces = tracesResponse.data || []
const latestTrace = traces[0]; const latestTrace = traces[0]
if (!latestTrace) { if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace // No trace found for this session - create a standalone feedback trace
const traceId = randomUUID(); const traceId = randomUUID()
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({ await langfuse.api.ingestion.batch({
batch: [ batch: [
{ {
type: 'trace-create', type: "trace-create",
id: randomUUID(), id: randomUUID(),
timestamp, timestamp,
body: { body: {
id: traceId, id: traceId,
name: 'user-feedback', name: "user-feedback",
sessionId, sessionId,
userId, userId,
input: { messageId, feedback }, input: { messageId, feedback },
metadata: { source: 'feedback-button', note: 'standalone - no chat trace found' }, metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
timestamp, timestamp,
}, },
}, },
{ {
type: 'score-create', type: "score-create",
id: randomUUID(), id: randomUUID(),
timestamp, timestamp,
body: { body: {
id: randomUUID(), id: randomUUID(),
traceId, traceId,
name: 'user-feedback', name: "user-feedback",
value: feedback === 'good' ? 1 : 0, value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`, comment: `User gave ${feedback} feedback`,
}, },
}, },
], ],
}); })
} else { } else {
// Attach score to the existing chat trace // Attach score to the existing chat trace
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({ await langfuse.api.ingestion.batch({
batch: [ batch: [
{ {
type: 'score-create', type: "score-create",
id: randomUUID(), id: randomUUID(),
timestamp, timestamp,
body: { body: {
id: randomUUID(), id: randomUUID(),
traceId: latestTrace.id, traceId: latestTrace.id,
name: 'user-feedback', name: "user-feedback",
value: feedback === 'good' ? 1 : 0, value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`, comment: `User gave ${feedback} feedback`,
}, },
}, },
], ],
}); })
} }
return Response.json({ success: true, logged: true }); return Response.json({ success: true, logged: true })
} catch (error) { } catch (error) {
console.error('Langfuse feedback error:', error); console.error("Langfuse feedback error:", error)
return Response.json({ success: false, error: 'Failed to log feedback' }, { status: 500 }); return Response.json(
{ success: false, error: "Failed to log feedback" },
{ status: 500 },
)
} }
} }

View File

@@ -1,65 +1,71 @@
import { getLangfuseClient } from '@/lib/langfuse'; import { randomUUID } from "crypto"
import { randomUUID } from 'crypto'; import { z } from "zod"
import { z } from 'zod'; import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({ const saveSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
format: z.enum(['drawio', 'png', 'svg']), format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(), sessionId: z.string().min(1).max(200).optional(),
}); })
export async function POST(req: Request) { export async function POST(req: Request) {
const langfuse = getLangfuseClient(); const langfuse = getLangfuseClient()
if (!langfuse) { if (!langfuse) {
return Response.json({ success: true, logged: false }); return Response.json({ success: true, logged: false })
} }
// Validate input // Validate input
let data; let data
try { try {
data = saveSchema.parse(await req.json()); data = saveSchema.parse(await req.json())
} catch { } catch {
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 }); return Response.json(
{ success: false, error: "Invalid input" },
{ status: 400 },
)
} }
const { filename, format, sessionId } = data; const { filename, format, sessionId } = data
try { try {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString()
// Find the most recent chat trace for this session to attach the save flag // Find the most recent chat trace for this session to attach the save flag
const tracesResponse = await langfuse.api.trace.list({ const tracesResponse = await langfuse.api.trace.list({
sessionId, sessionId,
limit: 1, limit: 1,
}); })
const traces = tracesResponse.data || []; const traces = tracesResponse.data || []
const latestTrace = traces[0]; const latestTrace = traces[0]
if (latestTrace) { if (latestTrace) {
// Add a score to the existing trace to flag that user saved // Add a score to the existing trace to flag that user saved
await langfuse.api.ingestion.batch({ await langfuse.api.ingestion.batch({
batch: [ batch: [
{ {
type: 'score-create', type: "score-create",
id: randomUUID(), id: randomUUID(),
timestamp, timestamp,
body: { body: {
id: randomUUID(), id: randomUUID(),
traceId: latestTrace.id, traceId: latestTrace.id,
name: 'diagram-saved', name: "diagram-saved",
value: 1, value: 1,
comment: `User saved diagram as ${filename}.${format}`, comment: `User saved diagram as ${filename}.${format}`,
}, },
}, },
], ],
}); })
} }
// If no trace found, skip logging (user hasn't chatted yet) // If no trace found, skip logging (user hasn't chatted yet)
return Response.json({ success: true, logged: !!latestTrace }); return Response.json({ success: true, logged: !!latestTrace })
} catch (error) { } catch (error) {
console.error('Langfuse save error:', error); console.error("Langfuse save error:", error)
return Response.json({ success: false, error: 'Failed to log save' }, { status: 500 }); return Response.json(
{ success: false, error: "Failed to log save" },
{ status: 500 },
)
} }
} }

View File

@@ -68,14 +68,14 @@
/* Light muted tones */ /* Light muted tones */
--muted: oklch(0.965 0.005 260); --muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.50 0.02 260); --muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */ /* Soft lavender accent */
--accent: oklch(0.94 0.03 280); --accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270); --accent-foreground: oklch(0.35 0.08 270);
/* Coral destructive */ /* Coral destructive */
--destructive: oklch(0.60 0.20 25); --destructive: oklch(0.6 0.2 25);
/* Subtle borders */ /* Subtle borders */
--border: oklch(0.92 0.01 260); --border: oklch(0.92 0.01 260);
@@ -85,9 +85,9 @@
/* Chart colors - harmonious palette */ /* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265); --chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170); --chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.70 0.18 45); --chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.60 0.20 330); --chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.50 0.15 200); --chart-5: oklch(0.5 0.15 200);
/* Sidebar */ /* Sidebar */
--sidebar: oklch(0.99 0.002 260); --sidebar: oklch(0.99 0.002 260);
@@ -104,44 +104,44 @@
--background: oklch(0.15 0.015 260); --background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260); --foreground: oklch(0.95 0.01 260);
--card: oklch(0.20 0.015 260); --card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260); --card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.20 0.015 260); --popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260); --popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.70 0.16 265); --primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260); --primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260); --secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.90 0.01 260); --secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260); --muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260); --muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.30 0.04 280); --accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.90 0.03 270); --accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.65 0.22 25); --destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260); --border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260); --input: oklch(0.25 0.015 260);
--ring: oklch(0.70 0.16 265); --ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.70 0.16 265); --chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.70 0.13 170); --chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45); --chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.70 0.18 330); --chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.60 0.13 200); --chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260); --sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260); --sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.70 0.16 265); --sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260); --sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270); --sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.90 0.02 265); --sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260); --sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.70 0.16 265); --sidebar-ring: oklch(0.7 0.16 265);
} }
@layer base { @layer base {
@@ -248,7 +248,11 @@
/* Gradient text utility */ /* Gradient text utility */
.text-gradient-primary { .text-gradient-primary {
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290)); background: linear-gradient(
135deg,
oklch(0.55 0.18 265),
oklch(0.6 0.2 290)
);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;

View File

@@ -1,41 +1,53 @@
import type { Metadata, Viewport } from "next"; import { GoogleAnalytics } from "@next/third-parties/google"
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/react"
import { Analytics } from "@vercel/analytics/react"; import type { Metadata, Viewport } from "next"
import { GoogleAnalytics } from "@next/third-parties/google"; import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google"
import { DiagramProvider } from "@/contexts/diagram-context"; import { DiagramProvider } from "@/contexts/diagram-context"
import "./globals.css"; import "./globals.css"
const plusJakarta = Plus_Jakarta_Sans({ const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans", variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"], weight: ["400", "500", "600", "700"],
}); })
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono", variable: "--font-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500"], weight: ["400", "500"],
}); })
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 1,
userScalable: false, userScalable: false,
}; }
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Next AI Draw.io - AI-Powered Diagram Generator", title: "Next AI Draw.io - AI-Powered Diagram Generator",
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", description:
keywords: ["AI diagram generator", "AWS architecture", "flowchart creator", "draw.io", "AI drawing tool", "technical diagrams", "diagram automation", "free diagram generator", "online diagram maker"], "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
keywords: [
"AI diagram generator",
"AWS architecture",
"flowchart creator",
"draw.io",
"AI drawing tool",
"technical diagrams",
"diagram automation",
"free diagram generator",
"online diagram maker",
],
authors: [{ name: "Next AI Draw.io" }], authors: [{ name: "Next AI Draw.io" }],
creator: "Next AI Draw.io", creator: "Next AI Draw.io",
publisher: "Next AI Draw.io", publisher: "Next AI Draw.io",
metadataBase: new URL("https://next-ai-drawio.jiang.jp"), metadataBase: new URL("https://next-ai-drawio.jiang.jp"),
openGraph: { openGraph: {
title: "Next AI Draw.io - AI Diagram Generator", title: "Next AI Draw.io - AI Diagram Generator",
description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.", description:
"Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
type: "website", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
@@ -52,7 +64,8 @@ export const metadata: Metadata = {
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "Next AI Draw.io - AI Diagram Generator", title: "Next AI Draw.io - AI Diagram Generator",
description: "Create professional diagrams with AI assistance. Free, no login required.", description:
"Create professional diagrams with AI assistance. Free, no login required.",
images: ["/architecture.png"], images: ["/architecture.png"],
}, },
robots: { robots: {
@@ -69,27 +82,28 @@ export const metadata: Metadata = {
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
}, },
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'SoftwareApplication', "@type": "SoftwareApplication",
name: 'Next AI Draw.io', name: "Next AI Draw.io",
applicationCategory: 'DesignApplication', applicationCategory: "DesignApplication",
operatingSystem: 'Web Browser', operatingSystem: "Web Browser",
description: 'AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.', description:
url: 'https://next-ai-drawio.jiang.jp', "AI-powered diagram generator with targeted XML editing capabilities that integrates with draw.io for creating AWS architecture diagrams, flowcharts, and technical diagrams. Features diagram history, multi-provider AI support, and real-time collaboration.",
url: "https://next-ai-drawio.jiang.jp",
offers: { offers: {
'@type': 'Offer', "@type": "Offer",
price: '0', price: "0",
priceCurrency: 'USD', priceCurrency: "USD",
}, },
}; }
return ( return (
<html lang="en"> <html lang="en">
@@ -109,5 +123,5 @@ export default function RootLayout({
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} /> <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
)} )}
</html> </html>
); )
} }

View File

@@ -1,75 +1,75 @@
"use client"; "use client"
import React, { useState, useEffect, useRef } from "react"; import React, { useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"; import { DrawIoEmbed } from "react-drawio"
import ChatPanel from "@/components/chat-panel"; import type { ImperativePanelHandle } from "react-resizable-panels"
import { useDiagram } from "@/contexts/diagram-context"; import ChatPanel from "@/components/chat-panel"
import { import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle, ResizableHandle,
} from "@/components/ui/resizable"; ResizablePanel,
import type { ImperativePanelHandle } from "react-resizable-panels"; ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram(); const { drawioRef, handleDiagramExport } = useDiagram()
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true); const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => { const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme"); const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") return saved; if (saved === "min" || saved === "sketch") return saved
} }
return "min"; return "min"
}); })
const chatPanelRef = useRef<ImperativePanelHandle>(null); const chatPanelRef = useRef<ImperativePanelHandle>(null)
useEffect(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768)
}; }
checkMobile(); checkMobile()
window.addEventListener("resize", checkMobile); window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile)
}, []); }, [])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current; const panel = chatPanelRef.current
if (panel) { if (panel) {
if (panel.isCollapsed()) { if (panel.isCollapsed()) {
panel.expand(); panel.expand()
setIsChatVisible(true); setIsChatVisible(true)
} else { } else {
panel.collapse(); panel.collapse()
setIsChatVisible(false); setIsChatVisible(false)
}
} }
} }
};
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") { if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault(); event.preventDefault()
toggleChatPanel(); toggleChatPanel()
}
} }
};
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown)
}, []); }, [])
// Show confirmation dialog when user tries to leave the page // Show confirmation dialog when user tries to leave the page
// This helps prevent accidental navigation from browser back gestures // This helps prevent accidental navigation from browser back gestures
useEffect(() => { useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault(); event.preventDefault()
return ""; return ""
}; }
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload)
return () => return () =>
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload)
}, []); }, [])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <div className="h-screen bg-background relative overflow-hidden">
@@ -80,7 +80,11 @@ export default function Home() {
> >
{/* Draw.io Canvas */} {/* Draw.io Canvas */}
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}> <ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
<div className={`h-full relative ${isMobile ? "p-1" : "p-2"}`}> <div
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
<DrawIoEmbed <DrawIoEmbed
key={drawioUi} key={drawioUi}
@@ -117,9 +121,10 @@ export default function Home() {
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={() => { onToggleDrawioUi={() => {
const newTheme = drawioUi === "min" ? "sketch" : "min"; const newTheme =
localStorage.setItem("drawio-theme", newTheme); drawioUi === "min" ? "sketch" : "min"
setDrawioUi(newTheme); localStorage.setItem("drawio-theme", newTheme)
setDrawioUi(newTheme)
}} }}
isMobile={isMobile} isMobile={isMobile}
/> />
@@ -127,5 +132,5 @@ export default function Home() {
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
); )
} }

View File

@@ -1,12 +1,12 @@
import { MetadataRoute } from 'next' import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { rules: {
userAgent: '*', userAgent: "*",
allow: '/', allow: "/",
disallow: '/api/', disallow: "/api/",
}, },
sitemap: 'https://next-ai-drawio.jiang.jp/sitemap.xml', sitemap: "https://next-ai-drawio.jiang.jp/sitemap.xml",
} }
} }

View File

@@ -1,17 +1,17 @@
import { MetadataRoute } from 'next' import type { MetadataRoute } from "next"
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: 'https://next-ai-drawio.jiang.jp', url: "https://next-ai-drawio.jiang.jp",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
{ {
url: 'https://next-ai-drawio.jiang.jp/about', url: "https://next-ai-drawio.jiang.jp/about",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: "monthly",
priority: 0.8, priority: 0.8,
}, },
] ]

59
biome.json Normal file
View File

@@ -0,0 +1,59 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noImportantStyles": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["components/ui/**"],
"formatter": {
"enabled": false
},
"linter": {
"enabled": false
},
"assist": {
"enabled": false
}
}
]
}

View File

@@ -1,19 +1,19 @@
import React from "react"; import type { VariantProps } from "class-variance-authority"
import { Button, buttonVariants } from "@/components/ui/button"; import type React from "react"
import { Button, type buttonVariants } from "@/components/ui/button"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip"
import { type VariantProps } from "class-variance-authority";
interface ButtonWithTooltipProps interface ButtonWithTooltipProps
extends React.ComponentProps<"button">, extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
tooltipContent: string; tooltipContent: string
children: React.ReactNode; children: React.ReactNode
asChild?: boolean; asChild?: boolean
} }
export function ButtonWithTooltip({ export function ButtonWithTooltip({
@@ -27,8 +27,10 @@ export function ButtonWithTooltip({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button {...buttonProps}>{children}</Button> <Button {...buttonProps}>{children}</Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">{tooltipContent}</TooltipContent> <TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); )
} }

View File

@@ -1,12 +1,12 @@
"use client"; "use client"
import { Zap, Cloud, GitBranch, Palette } from "lucide-react"; import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps { interface ExampleCardProps {
icon: React.ReactNode; icon: React.ReactNode
title: string; title: string
description: string; description: string
onClick: () => void; onClick: () => void
} }
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) { function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
</div> </div>
</div> </div>
</button> </button>
); )
} }
export default function ExamplePanel({ export default function ExamplePanel({
setInput, setInput,
setFiles, setFiles,
}: { }: {
setInput: (input: string) => void; setInput: (input: string) => void
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void
}) { }) {
const handleReplicateFlowchart = async () => { const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart."); setInput("Replicate this flowchart.")
try { try {
const response = await fetch("/example.png"); const response = await fetch("/example.png")
const blob = await response.blob(); const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" }); const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file]); setFiles([file])
} catch (error) { } catch (error) {
console.error("Error loading example image:", error); console.error("Error loading example image:", error)
}
} }
};
const handleReplicateArchitecture = async () => { const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style"); setInput("Replicate this in aws style")
try { try {
const response = await fetch("/architecture.png"); const response = await fetch("/architecture.png")
const blob = await response.blob(); const blob = await response.blob()
const file = new File([blob], "architecture.png", { const file = new File([blob], "architecture.png", {
type: "image/png", type: "image/png",
}); })
setFiles([file]); setFiles([file])
} catch (error) { } catch (error) {
console.error("Error loading architecture image:", error); console.error("Error loading architecture image:", error)
}
} }
};
return ( return (
<div className="py-6 px-2 animate-fade-in"> <div className="py-6 px-2 animate-fade-in">
@@ -75,7 +75,8 @@ export default function ExamplePanel({
Create diagrams with AI Create diagrams with AI
</h2> </h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto"> <p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to replicate Describe what you want to create or upload an image to
replicate
</p> </p>
</div> </div>
@@ -91,7 +92,9 @@ export default function ExamplePanel({
title="Animated Diagram" title="Animated Diagram"
description="Draw a transformer architecture with animated connectors" description="Draw a transformer architecture with animated connectors"
onClick={() => { onClick={() => {
setInput("Give me a **animated connector** diagram of transformer's architecture") setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([]) setFiles([])
}} }}
/> />
@@ -126,5 +129,5 @@ export default function ExamplePanel({
</p> </p>
</div> </div>
</div> </div>
); )
} }

View File

@@ -1,10 +1,24 @@
"use client"; "use client"
import React, { useCallback, useRef, useEffect, useState } from "react"; import {
import { Button } from "@/components/ui/button"; Download,
import { Textarea } from "@/components/ui/textarea"; History,
import { ResetWarningModal } from "@/components/reset-warning-modal"; Image as ImageIcon,
import { SaveDialog } from "@/components/save-dialog"; LayoutGrid,
Loader2,
PenTool,
Send,
Trash2,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,103 +26,105 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
import { import { Textarea } from "@/components/ui/textarea"
Loader2, import { useDiagram } from "@/contexts/diagram-context"
Send, import { FilePreviewList } from "./file-preview-list"
Trash2,
Image as ImageIcon,
History,
Download,
PenTool,
LayoutGrid,
} from "lucide-react";
import { toast } from "sonner";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context";
import { HistoryDialog } from "@/components/history-dialog";
import { ErrorToast } from "@/components/error-toast";
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5; const MAX_FILES = 5
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024; const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`; if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`; return `${mb.toFixed(2)}MB`
} }
function showErrorToast(message: React.ReactNode) { function showErrorToast(message: React.ReactNode) {
toast.custom( toast.custom(
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />, (t) => (
{ duration: 5000 } <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
); ),
{ duration: 5000 },
)
} }
interface ValidationResult { interface ValidationResult {
validFiles: File[]; validFiles: File[]
errors: string[]; errors: string[]
} }
function validateFiles(newFiles: File[], existingCount: number): ValidationResult { function validateFiles(
const errors: string[] = []; newFiles: File[],
const validFiles: File[] = []; existingCount: number,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount; const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) { if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`); errors.push(`Maximum ${MAX_FILES} files allowed`)
return { validFiles, errors }; return { validFiles, errors }
} }
for (const file of newFiles) { for (const file of newFiles) {
if (validFiles.length >= availableSlots) { if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`); errors.push(`Only ${availableSlots} more file(s) allowed`)
break; break
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`); errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
)
} else { } else {
validFiles.push(file); validFiles.push(file)
} }
} }
return { validFiles, errors }; return { validFiles, errors }
} }
function showValidationErrors(errors: string[]) { function showValidationErrors(errors: string[]) {
if (errors.length === 0) return; if (errors.length === 0) return
if (errors.length === 1) { if (errors.length === 1) {
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>); showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else { } else {
showErrorToast( showErrorToast(
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium">{errors.length} files rejected:</span> <span className="font-medium">
{errors.length} files rejected:
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside"> <ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)} {errors.slice(0, 3).map((err, i) => (
{errors.length > 3 && <li>...and {errors.length - 3} more</li>} <li key={i}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
)}
</ul> </ul>
</div> </div>,
); )
} }
} }
interface ChatInputProps { interface ChatInputProps {
input: string; input: string
status: "submitted" | "streaming" | "ready" | "error"; status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void; onClearChat: () => void
files?: File[]; files?: File[]
onFileChange?: (files: File[]) => void; onFileChange?: (files: File[]) => void
showHistory?: boolean; showHistory?: boolean
onToggleHistory?: (show: boolean) => void; onToggleHistory?: (show: boolean) => void
sessionId?: string; sessionId?: string
error?: Error | null; error?: Error | null
drawioUi?: "min" | "sketch"; drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void; onToggleDrawioUi?: () => void
} }
export function ChatInput({ export function ChatInput({
@@ -126,128 +142,133 @@ export function ChatInput({
drawioUi = "min", drawioUi = "min",
onToggleDrawioUi = () => {}, onToggleDrawioUi = () => {},
}: ChatInputProps) { }: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram(); const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false); const [showThemeWarning, setShowThemeWarning] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
(status === "streaming" || status === "submitted") && !error; (status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => { const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current
if (textarea) { if (textarea) {
textarea.style.height = "auto"; textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
adjustTextareaHeight(); adjustTextareaHeight()
}, [input, adjustTextareaHeight]); }, [input, adjustTextareaHeight])
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault(); e.preventDefault()
const form = e.currentTarget.closest("form"); const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
form.requestSubmit(); form.requestSubmit()
}
} }
} }
};
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return; if (isDisabled) return
const items = e.clipboardData.items; const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/") item.type.startsWith("image/"),
); )
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = (await Promise.all( const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => { imageItems.map(async (item, index) => {
const file = item.getAsFile(); const file = item.getAsFile()
if (!file) return null; if (!file) return null
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type } { type: file.type },
); )
}) }),
)).filter((f): f is File => f !== null); )
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(imageFiles, files.length); const { validFiles, errors } = validateFiles(
showValidationErrors(errors); imageFiles,
files.length,
)
showValidationErrors(errors)
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]); onFileChange([...files, ...validFiles])
}
} }
} }
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []); const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length); const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors); showValidationErrors(errors)
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]); onFileChange([...files, ...validFiles])
} }
// Reset input so same file can be selected again // Reset input so same file can be selected again
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = ""
}
} }
};
const handleRemoveFile = (fileToRemove: File) => { const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove)); onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = ""
}
} }
};
const triggerFileInput = () => { const triggerFileInput = () => {
fileInputRef.current?.click(); fileInputRef.current?.click()
}; }
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(true); setIsDragging(true)
}; }
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => { const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(false); setIsDragging(false)
}; }
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => { const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
setIsDragging(false); setIsDragging(false)
if (isDisabled) return; if (isDisabled) return
const droppedFiles = e.dataTransfer.files; const droppedFiles = e.dataTransfer.files
const imageFiles = Array.from(droppedFiles).filter((file) => const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/") file.type.startsWith("image/"),
); )
const { validFiles, errors } = validateFiles(imageFiles, files.length); const { validFiles, errors } = validateFiles(imageFiles, files.length)
showValidationErrors(errors); showValidationErrors(errors)
if (validFiles.length > 0) { if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]); onFileChange([...files, ...validFiles])
}
} }
};
const handleClear = () => { const handleClear = () => {
onClearChat(); onClearChat()
setShowClearDialog(false); setShowClearDialog(false)
}; }
return ( return (
<form <form
@@ -316,7 +337,11 @@ export function ChatInput({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowThemeWarning(true)} onClick={() => setShowThemeWarning(true)}
tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"} tooltipContent={
drawioUi === "min"
? "Switch to Sketch theme"
: "Switch to Minimal theme"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
{drawioUi === "min" ? ( {drawioUi === "min" ? (
@@ -326,27 +351,33 @@ export function ChatInput({
)} )}
</ButtonWithTooltip> </ButtonWithTooltip>
<Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}> <Dialog
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle> <DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription> <DialogDescription>
Switching themes will reload the diagram editor and clear any unsaved changes. Switching themes will reload the diagram
editor and clear any unsaved changes.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowThemeWarning(false)} onClick={() =>
setShowThemeWarning(false)
}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
onClearChat(); onClearChat()
onToggleDrawioUi(); onToggleDrawioUi()
setShowThemeWarning(false); setShowThemeWarning(false)
}} }}
> >
Switch Theme Switch Theme
@@ -439,5 +470,5 @@ export function ChatInput({
</div> </div>
</div> </div>
</form> </form>
); )
} }

View File

@@ -1,25 +1,45 @@
"use client"; "use client"
import { useRef, useEffect, useState, useCallback } from "react"; import type { UIMessage } from "ai"
import Image from "next/image"; import {
import ReactMarkdown from "react-markdown"; Check,
import { ScrollArea } from "@/components/ui/scroll-area"; ChevronDown,
import ExamplePanel from "./chat-example-panel"; ChevronUp,
import { UIMessage } from "ai"; Copy,
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils"; Cpu,
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react"; Minus,
import { CodeBlock } from "./code-block"; Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
convertToLegalXml,
replaceNodes,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface EditPair { interface EditPair {
search: string; search: string
replace: string; replace: string
} }
function EditDiffDisplay({ edits }: { edits: EditPair[] }) { function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{edits.map((edit, index) => ( {edits.map((edit, index) => (
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50"> <div
key={index}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Change {index + 1} Change {index + 1}
@@ -30,7 +50,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Minus className="w-3 h-3 text-red-500" /> <Minus className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span> <span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div> </div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search} {edit.search}
@@ -40,7 +62,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2"> <div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" /> <Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span> <span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div> </div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace} {edit.replace}
@@ -50,26 +74,26 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
</div> </div>
))} ))}
</div> </div>
); )
} }
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => { const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return ""; if (!message.parts) return ""
return message.parts return message.parts
.filter((part: any) => part.type === "text") .filter((part: any) => part.type === "text")
.map((part: any) => part.text) .map((part: any) => part.text)
.join("\n"); .join("\n")
}; }
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[]; messages: UIMessage[]
setInput: (input: string) => void; setInput: (input: string) => void
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void
sessionId?: string; sessionId?: string
onRegenerate?: (messageIndex: number) => void; onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void; onEditMessage?: (messageIndex: number, newText: string) => void
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -80,43 +104,47 @@ export function ChatMessageDisplay({
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram(); const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>(""); const previousXML = useRef<string>("")
const processedToolCalls = useRef<Set<string>>(new Set()); const processedToolCalls = useRef<Set<string>>(new Set())
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>( const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{} {},
); )
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null); const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null); const [copyFailedMessageId, setCopyFailedMessageId] = useState<
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({}); string | null
const [editingMessageId, setEditingMessageId] = useState<string | null>(null); >(null)
const [editText, setEditText] = useState<string>(""); const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
const [editingMessageId, setEditingMessageId] = useState<string | null>(
null,
)
const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => { const copyMessageToClipboard = async (messageId: string, text: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId); setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000); setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) { } catch (err) {
console.error("Failed to copy message:", err); console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId); setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000); setTimeout(() => setCopyFailedMessageId(null), 2000)
}
} }
};
const submitFeedback = async (messageId: string, value: "good" | "bad") => { const submitFeedback = async (messageId: string, value: "good" | "bad") => {
// Toggle off if already selected // Toggle off if already selected
if (feedback[messageId] === value) { if (feedback[messageId] === value) {
setFeedback((prev) => { setFeedback((prev) => {
const next = { ...prev }; const next = { ...prev }
delete next[messageId]; delete next[messageId]
return next; return next
}); })
return; return
} }
setFeedback((prev) => ({ ...prev, [messageId]: value })); setFeedback((prev) => ({ ...prev, [messageId]: value }))
try { try {
await fetch("/api/log-feedback", { await fetch("/api/log-feedback", {
@@ -127,49 +155,52 @@ export function ChatMessageDisplay({
feedback: value, feedback: value,
sessionId, sessionId,
}), }),
}); })
} catch (error) { } catch (error) {
console.warn("Failed to log feedback:", error); console.warn("Failed to log feedback:", error)
}
} }
};
const handleDisplayChart = useCallback( const handleDisplayChart = useCallback(
(xml: string) => { (xml: string) => {
const currentXml = xml || ""; const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml); const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml); const replacedXML = replaceNodes(chartXML, convertedXml)
const validationError = validateMxCellStructure(replacedXML); const validationError = validateMxCellStructure(replacedXML)
if (!validationError) { if (!validationError) {
previousXML.current = convertedXml; previousXML.current = convertedXml
onDisplayChart(replacedXML); onDisplayChart(replacedXML)
} else { } else {
console.log("[ChatMessageDisplay] XML validation failed:", validationError); console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
} }
} }
}, },
[chartXML, onDisplayChart] [chartXML, onDisplayChart],
); )
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]); }, [messages])
useEffect(() => { useEffect(() => {
messages.forEach((message) => { messages.forEach((message) => {
if (message.parts) { if (message.parts) {
message.parts.forEach((part: any) => { message.parts.forEach((part: any) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part; const { toolCallId, state } = part
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
...prev, ...prev,
[toolCallId]: false, [toolCallId]: false,
})); }))
} }
if ( if (
@@ -180,44 +211,44 @@ export function ChatMessageDisplay({
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
handleDisplayChart(part.input.xml); handleDisplayChart(part.input.xml)
} else if ( } else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
handleDisplayChart(part.input.xml); handleDisplayChart(part.input.xml)
processedToolCalls.current.add(toolCallId); processedToolCalls.current.add(toolCallId)
} }
} }
} }
}); })
} }
}); })
}, [messages, handleDisplayChart]); }, [messages, handleDisplayChart])
const renderToolPart = (part: any) => { const renderToolPart = (part: any) => {
const callId = part.toolCallId; const callId = part.toolCallId
const { state, input, output } = part; const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true; const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", ""); const toolName = part.type?.replace("tool-", "")
const toggleExpanded = () => { const toggleExpanded = () => {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
...prev, ...prev,
[callId]: !isExpanded, [callId]: !isExpanded,
})); }))
}; }
const getToolDisplayName = (name: string) => { const getToolDisplayName = (name: string) => {
switch (name) { switch (name) {
case "display_diagram": case "display_diagram":
return "Generate Diagram"; return "Generate Diagram"
case "edit_diagram": case "edit_diagram":
return "Edit Diagram"; return "Edit Diagram"
default: default:
return name; return name
}
} }
};
return ( return (
<div <div
@@ -265,10 +296,16 @@ export function ChatMessageDisplay({
<div className="px-4 py-3 border-t border-border/40 bg-muted/20"> <div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? ( {typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" /> <CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? ( ) : typeof input === "object" &&
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} /> <EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && Object.keys(input).length > 0 ? ( ) : typeof input === "object" &&
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" /> Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null} ) : null}
</div> </div>
)} )}
@@ -278,8 +315,8 @@ export function ChatMessageDisplay({
</div> </div>
)} )}
</div> </div>
); )
}; }
return ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
@@ -288,30 +325,46 @@ export function ChatMessageDisplay({
) : ( ) : (
<div className="py-4 px-4 space-y-4"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : ""; const userMessageText =
const isLastAssistantMessage = message.role === "assistant" && ( message.role === "user"
messageIndex === messages.length - 1 || ? getMessageTextContent(message)
messages.slice(messageIndex + 1).every(m => m.role !== "assistant") : ""
); const isLastAssistantMessage =
const isLastUserMessage = message.role === "user" && ( message.role === "assistant" &&
messageIndex === messages.length - 1 || (messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "user") messages
); .slice(messageIndex + 1)
const isEditing = editingMessageId === message.id; .every((m) => m.role !== "assistant"))
const isLastUserMessage =
message.role === "user" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }} style={{
animationDelay: `${messageIndex * 50}ms`,
}}
> >
{message.role === "user" && userMessageText && !isEditing && ( {message.role === "user" &&
userMessageText &&
!isEditing && (
<div className="flex items-center gap-1 self-center mr-2"> <div className="flex items-center gap-1 self-center mr-2">
{/* Edit button - only on last user message */} {/* Edit button - only on last user message */}
{onEditMessage && isLastUserMessage && ( {onEditMessage &&
isLastUserMessage && (
<button <button
onClick={() => { onClick={() => {
setEditingMessageId(message.id); setEditingMessageId(
setEditText(userMessageText); message.id,
)
setEditText(
userMessageText,
)
}} }}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message" title="Edit message"
@@ -320,13 +373,28 @@ export function ChatMessageDisplay({
</button> </button>
)} )}
<button <button
onClick={() => copyMessageToClipboard(message.id, userMessageText)} onClick={() =>
copyMessageToClipboard(
message.id,
userMessageText,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"} title={
copiedMessageId ===
message.id
? "Copied!"
: copyFailedMessageId ===
message.id
? "Failed to copy"
: "Copy message"
}
> >
{copiedMessageId === message.id ? ( {copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" /> <Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId === message.id ? ( ) : copyFailedMessageId ===
message.id ? (
<X className="h-3.5 w-3.5 text-red-500" /> <X className="h-3.5 w-3.5 text-red-500" />
) : ( ) : (
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -340,20 +408,39 @@ export function ChatMessageDisplay({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<textarea <textarea
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) =>
setEditText(e.target.value)
}
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary" className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
rows={Math.min(editText.split('\n').length + 1, 6)} rows={Math.min(
editText.split("\n")
.length + 1,
6,
)}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingMessageId(null); setEditingMessageId(
setEditText(""); null,
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { )
e.preventDefault(); setEditText("")
if (editText.trim() && onEditMessage) { } else if (
onEditMessage(messageIndex, editText.trim()); e.key === "Enter" &&
setEditingMessageId(null); (e.metaKey || e.ctrlKey)
setEditText(""); ) {
e.preventDefault()
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
} }
}} }}
@@ -361,8 +448,10 @@ export function ChatMessageDisplay({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
onClick={() => { onClick={() => {
setEditingMessageId(null); setEditingMessageId(
setEditText(""); null,
)
setEditText("")
}} }}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors" className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
> >
@@ -370,10 +459,18 @@ export function ChatMessageDisplay({
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (editText.trim() && onEditMessage) { if (
onEditMessage(messageIndex, editText.trim()); editText.trim() &&
setEditingMessageId(null); onEditMessage
setEditText(""); ) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
}} }}
disabled={!editText.trim()} disabled={!editText.trim()}
@@ -385,87 +482,153 @@ export function ChatMessageDisplay({
</div> </div>
) : ( ) : (
/* Text content in bubble */ /* Text content in bubble */
message.parts?.some((part: any) => part.type === "text" || part.type === "file") && ( message.parts?.some(
(part: any) =>
part.type === "text" ||
part.type === "file",
) && (
<div <div
className={`px-4 py-3 text-sm leading-relaxed ${ className={`px-4 py-3 text-sm leading-relaxed ${
message.role === "user" message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm" ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
: message.role === "system" : message.role ===
"system"
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md" ? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md" : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`} } ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
onClick={() => { onClick={() => {
if (message.role === "user" && isLastUserMessage && onEditMessage) { if (
setEditingMessageId(message.id); message.role ===
setEditText(userMessageText); "user" &&
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
} }
}} }}
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined} title={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "Click to edit"
: undefined
}
> >
{message.parts?.map((part: any, index: number) => { {message.parts?.map(
(
part: any,
index: number,
) => {
switch (part.type) { switch (part.type) {
case "text": case "text":
return ( return (
<div key={index} className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${ <div
message.role === "user" key={
index
}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20" ? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert" : "dark:prose-invert"
}`}> }`}
<ReactMarkdown>{part.text}</ReactMarkdown> >
<ReactMarkdown>
{
part.text
}
</ReactMarkdown>
</div> </div>
); )
case "file": case "file":
return ( return (
<div key={index} className="mt-2"> <div
key={
index
}
className="mt-2"
>
<Image <Image
src={part.url} src={
width={200} part.url
height={200} }
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`} alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20" className="rounded-lg border border-white/20"
style={{ style={{
objectFit: "contain", objectFit:
"contain",
}} }}
/> />
</div> </div>
); )
default: default:
return null; return null
} }
})} },
)}
</div> </div>
) )
)} )}
{/* Tool calls outside bubble */} {/* Tool calls outside bubble */}
{message.parts?.map((part: any) => { {message.parts?.map((part: any) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
return renderToolPart(part); return renderToolPart(part)
} }
return null; return null
})} })}
{/* Action buttons for assistant messages */} {/* Action buttons for assistant messages */}
{message.role === "assistant" && ( {message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
{/* Copy button */} {/* Copy button */}
<button <button
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))} onClick={() =>
copyMessageToClipboard(
message.id,
getMessageTextContent(
message,
),
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId === message.id copiedMessageId ===
message.id
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted" : "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
}`} }`}
title={copiedMessageId === message.id ? "Copied!" : "Copy response"} title={
copiedMessageId ===
message.id
? "Copied!"
: "Copy response"
}
> >
{copiedMessageId === message.id ? ( {copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
) : ( ) : (
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</button> </button>
{/* Regenerate button - only on last assistant message */} {/* Regenerate button - only on last assistant message */}
{onRegenerate && isLastAssistantMessage && ( {onRegenerate &&
isLastAssistantMessage && (
<button <button
onClick={() => onRegenerate(messageIndex)} onClick={() =>
onRegenerate(
messageIndex,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response" title="Regenerate response"
> >
@@ -476,9 +639,15 @@ export function ChatMessageDisplay({
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */} {/* Thumbs up */}
<button <button
onClick={() => submitFeedback(message.id, "good")} onClick={() =>
submitFeedback(
message.id,
"good",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "good" feedback[message.id] ===
"good"
? "text-green-600 bg-green-100" ? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50" : "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`} }`}
@@ -488,9 +657,15 @@ export function ChatMessageDisplay({
</button> </button>
{/* Thumbs down */} {/* Thumbs down */}
<button <button
onClick={() => submitFeedback(message.id, "bad")} onClick={() =>
submitFeedback(
message.id,
"bad",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "bad" feedback[message.id] ===
"bad"
? "text-red-600 bg-red-100" ? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50" : "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`} }`}
@@ -502,11 +677,11 @@ export function ChatMessageDisplay({
)} )}
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ScrollArea> </ScrollArea>
); )
} }

View File

@@ -1,37 +1,36 @@
"use client"; "use client"
import type React from "react"; import { useChat } from "@ai-sdk/react"
import { useRef, useEffect, useState } from "react"; import { DefaultChatTransport } from "ai"
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa";
import { import {
CheckCircle,
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
Settings, Settings,
CheckCircle, } from "lucide-react"
} from "lucide-react"; import Image from "next/image"
import Link from "next/link"; import Link from "next/link"
import Image from "next/image"; import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useChat } from "@ai-sdk/react"; import { flushSync } from "react-dom"
import { DefaultChatTransport } from "ai"; import { FaGithub } from "react-icons/fa"
import { ChatInput } from "@/components/chat-input"; import { Toaster } from "sonner"
import { ChatMessageDisplay } from "./chat-message-display"; import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDiagram } from "@/contexts/diagram-context"; import { ChatInput } from "@/components/chat-input"
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
import { import {
SettingsDialog, SettingsDialog,
STORAGE_ACCESS_CODE_KEY, STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog"; } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps { interface ChatPanelProps {
isVisible: boolean; isVisible: boolean
onToggleVisibility: () => void; onToggleVisibility: () => void
drawioUi: "min" | "sketch"; drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void; onToggleDrawioUi: () => void
isMobile?: boolean; isMobile?: boolean
} }
export default function ChatPanel({ export default function ChatPanel({
@@ -48,59 +47,61 @@ export default function ChatPanel({
resolverRef, resolverRef,
chartXML, chartXML,
clearDiagram, clearDiagram,
} = useDiagram(); } = useDiagram()
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) { if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve; resolverRef.current = resolve
} }
if (saveToHistory) { if (saveToHistory) {
onExport(); onExport()
} else { } else {
handleExportWithoutHistory(); handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => new Promise<string>((_, reject) =>
setTimeout( setTimeout(
() => () =>
reject( reject(
new Error("Chart export timed out after 10 seconds") new Error(
"Chart export timed out after 10 seconds",
), ),
10000
)
), ),
]); 10000,
}; ),
),
])
}
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false); const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false); const [accessCodeRequired, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState(""); const [input, setInput] = useState("")
// Check if access code is required on mount // Check if access code is required on mount
useEffect(() => { useEffect(() => {
fetch("/api/config") fetch("/api/config")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setAccessCodeRequired(data.accessCodeRequired)) .then((data) => setAccessCodeRequired(data.accessCodeRequired))
.catch(() => setAccessCodeRequired(false)); .catch(() => setAccessCodeRequired(false))
}, []); }, [])
// Generate a unique session ID for Langfuse tracing // Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState( const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` () => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
); )
// Store XML snapshots for each user message (keyed by message index) // Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map()); const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
// Ref to track latest chartXML for use in callbacks (avoids stale closure) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML); const chartXMLRef = useRef(chartXML)
useEffect(() => { useEffect(() => {
chartXMLRef.current = chartXML; chartXMLRef.current = chartXML
}, [chartXML]); }, [chartXML])
const { messages, sendMessage, addToolResult, status, error, setMessages } = const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({ useChat({
@@ -109,71 +110,71 @@ export default function ChatPanel({
}), }),
async onToolCall({ toolCall }) { async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") { if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }; const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml); const validationError = validateMxCellStructure(xml)
if (validationError) { if (validationError) {
addToolResult({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: validationError, output: validationError,
}); })
} else { } else {
addToolResult({ addToolResult({
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.", output: "Successfully displayed the diagram.",
}); })
} }
} else if (toolCall.toolName === "edit_diagram") { } else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as { const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>; edits: Array<{ search: string; replace: string }>
}; }
let currentXml = ""; let currentXml = ""
try { try {
console.log("[edit_diagram] Starting..."); console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export // Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues // especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback // Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current; const cachedXML = chartXMLRef.current
if (cachedXML) { if (cachedXML) {
currentXml = cachedXML; currentXml = cachedXML
console.log( console.log(
"[edit_diagram] Using cached chartXML, length:", "[edit_diagram] Using cached chartXML, length:",
currentXml.length currentXml.length,
); )
} else { } else {
// Fallback to export only if no cached XML // Fallback to export only if no cached XML
console.log( console.log(
"[edit_diagram] No cached XML, fetching from DrawIO..." "[edit_diagram] No cached XML, fetching from DrawIO...",
); )
currentXml = await onFetchChart(false); currentXml = await onFetchChart(false)
console.log( console.log(
"[edit_diagram] Got XML from export, length:", "[edit_diagram] Got XML from export, length:",
currentXml.length currentXml.length,
); )
} }
const { replaceXMLParts } = await import("@/lib/utils"); const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits); const editedXml = replaceXMLParts(currentXml, edits)
onDisplayChart(editedXml); onDisplayChart(editedXml)
addToolResult({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`, output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
}); })
console.log("[edit_diagram] Success"); console.log("[edit_diagram] Success")
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error); console.error("[edit_diagram] Failed:", error)
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
? error.message ? error.message
: String(error); : String(error)
addToolResult({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
@@ -186,14 +187,14 @@ ${currentXml || "No XML available"}
\`\`\` \`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`, Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
}); })
} }
} }
}, },
onError: (error) => { onError: (error) => {
// Silence access code error in console since it's handled by UI // Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) { if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error); console.error("Chat error:", error)
} }
// Add system message for error so it can be cleared // Add system message for error so it can be cleared
@@ -203,63 +204,63 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
role: "system" as const, role: "system" as const,
content: error.message, content: error.message,
parts: [{ type: "text" as const, text: error.message }], parts: [{ type: "text" as const, text: error.message }],
}; }
return [...currentMessages, errorMessage]; return [...currentMessages, errorMessage]
}); })
if (error.message.includes("Invalid or missing access code")) { if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it // Show settings button and open dialog to help user fix it
setAccessCodeRequired(true); setAccessCodeRequired(true)
setShowSettingsDialog(true); setShowSettingsDialog(true)
} }
}, },
}); })
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
} }
}, [messages]); }, [messages])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) { if (input.trim() && !isProcessing) {
try { try {
let chartXml = await onFetchChart(); let chartXml = await onFetchChart()
chartXml = formatXML(chartXml); chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update // Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds // This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml; chartXMLRef.current = chartXml
const parts: any[] = [{ type: "text", text: input }]; const parts: any[] = [{ type: "text", text: input }]
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
const reader = new FileReader(); const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve) => { const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => reader.onload = () =>
resolve(reader.result as string); resolve(reader.result as string)
reader.readAsDataURL(file); reader.readAsDataURL(file)
}); })
parts.push({ parts.push({
type: "file", type: "file",
url: dataUrl, url: dataUrl,
mediaType: file.type, mediaType: file.type,
}); })
} }
} }
// Save XML snapshot for this message (will be at index = current messages.length) // Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length; const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml); xmlSnapshotsRef.current.set(messageIndex, chartXml)
const accessCode = const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""; localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage( sendMessage(
{ parts }, { parts },
{ {
@@ -270,78 +271,78 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
headers: { headers: {
"x-access-code": accessCode, "x-access-code": accessCode,
}, },
} },
); )
setInput(""); setInput("")
setFiles([]); setFiles([])
} catch (error) { } catch (error) {
console.error("Error fetching chart data:", error); console.error("Error fetching chart data:", error)
}
} }
} }
};
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
setInput(e.target.value); setInput(e.target.value)
}; }
const handleFileChange = (newFiles: File[]) => { const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles); setFiles(newFiles)
}; }
const handleRegenerate = async (messageIndex: number) => { const handleRegenerate = async (messageIndex: number) => {
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return; if (isProcessing) return
// Find the user message before this assistant message // Find the user message before this assistant message
let userMessageIndex = messageIndex - 1; let userMessageIndex = messageIndex - 1
while ( while (
userMessageIndex >= 0 && userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user" messages[userMessageIndex].role !== "user"
) { ) {
userMessageIndex--; userMessageIndex--
} }
if (userMessageIndex < 0) return; if (userMessageIndex < 0) return
const userMessage = messages[userMessageIndex]; const userMessage = messages[userMessageIndex]
const userParts = userMessage.parts; const userParts = userMessage.parts
// Get the text from the user message // Get the text from the user message
const textPart = userParts?.find((p: any) => p.type === "text"); const textPart = userParts?.find((p: any) => p.type === "text")
if (!textPart) return; if (!textPart) return
// Get the saved XML snapshot for this user message // Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex); const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
if (!savedXml) { if (!savedXml) {
console.error( console.error(
"No saved XML snapshot for message index:", "No saved XML snapshot for message index:",
userMessageIndex userMessageIndex,
); )
return; return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state
onDisplayChart(savedXml); onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml; chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed) // Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) { for (const key of xmlSnapshotsRef.current.keys()) {
if (key > userMessageIndex) { if (key > userMessageIndex) {
xmlSnapshotsRef.current.delete(key); xmlSnapshotsRef.current.delete(key)
} }
} }
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending // Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, userMessageIndex); const newMessages = messages.slice(0, userMessageIndex)
flushSync(() => { flushSync(() => {
setMessages(newMessages); setMessages(newMessages)
}); })
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
sendMessage( sendMessage(
@@ -351,54 +352,54 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
},
)
} }
);
};
const handleEditMessage = async (messageIndex: number, newText: string) => { const handleEditMessage = async (messageIndex: number, newText: string) => {
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return; if (isProcessing) return
const message = messages[messageIndex]; const message = messages[messageIndex]
if (!message || message.role !== "user") return; if (!message || message.role !== "user") return
// Get the saved XML snapshot for this user message // Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(messageIndex); const savedXml = xmlSnapshotsRef.current.get(messageIndex)
if (!savedXml) { if (!savedXml) {
console.error( console.error(
"No saved XML snapshot for message index:", "No saved XML snapshot for message index:",
messageIndex messageIndex,
); )
return; return
} }
// Restore the diagram to the saved state // Restore the diagram to the saved state
onDisplayChart(savedXml); onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML // Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml; chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed) // Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) { for (const key of xmlSnapshotsRef.current.keys()) {
if (key > messageIndex) { if (key > messageIndex) {
xmlSnapshotsRef.current.delete(key); xmlSnapshotsRef.current.delete(key)
} }
} }
// Create new parts with updated text // Create new parts with updated text
const newParts = message.parts?.map((part: any) => { const newParts = message.parts?.map((part: any) => {
if (part.type === "text") { if (part.type === "text") {
return { ...part, text: newText }; return { ...part, text: newText }
} }
return part; return part
}) || [{ type: "text", text: newText }]; }) || [{ type: "text", text: newText }]
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message) // Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending // Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, messageIndex); const newMessages = messages.slice(0, messageIndex)
flushSync(() => { flushSync(() => {
setMessages(newMessages); setMessages(newMessages)
}); })
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
sendMessage( sendMessage(
@@ -408,9 +409,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml, xml: savedXml,
sessionId, sessionId,
}, },
},
)
} }
);
};
// Collapsed view (desktop only) // Collapsed view (desktop only)
if (!isVisible && !isMobile) { if (!isVisible && !isMobile) {
@@ -435,7 +436,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
AI Chat AI Chat
</div> </div>
</div> </div>
); )
} }
// Full view // Full view
@@ -447,7 +448,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
style={{ position: "absolute" }} style={{ position: "absolute" }}
/> />
{/* Header */} {/* Header */}
<header className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}> <header
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -458,7 +461,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
height={isMobile ? 24 : 28} height={isMobile ? 24 : 28}
className="rounded" className="rounded"
/> />
<h1 className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}> <h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
>
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
@@ -488,7 +493,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
<FaGithub className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} /> <FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a> </a>
{accessCodeRequired && ( {accessCodeRequired && (
<ButtonWithTooltip <ButtonWithTooltip
@@ -498,7 +505,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClick={() => setShowSettingsDialog(true)} onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent" className="hover:bg-accent"
> >
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} /> <Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip> </ButtonWithTooltip>
)} )}
{!isMobile && ( {!isMobile && (
@@ -529,21 +538,23 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</main> </main>
{/* Input */} {/* Input */}
<footer className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}> <footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
>
<ChatInput <ChatInput
input={input} input={input}
status={status} status={status}
onSubmit={onFormSubmit} onSubmit={onFormSubmit}
onChange={handleInputChange} onChange={handleInputChange}
onClearChat={() => { onClearChat={() => {
setMessages([]); setMessages([])
clearDiagram(); clearDiagram()
setSessionId( setSessionId(
`session-${Date.now()}-${Math.random() `session-${Date.now()}-${Math.random()
.toString(36) .toString(36)
.slice(2, 9)}` .slice(2, 9)}`,
); )
xmlSnapshotsRef.current.clear(); xmlSnapshotsRef.current.clear()
}} }}
files={files} files={files}
onFileChange={handleFileChange} onFileChange={handleFileChange}
@@ -561,5 +572,5 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
/> />
</div> </div>
); )
} }

View File

@@ -1,22 +1,29 @@
"use client"; "use client"
import { Highlight, themes } from "prism-react-renderer"; import { Highlight, themes } from "prism-react-renderer"
interface CodeBlockProps { interface CodeBlockProps {
code: string; code: string
language?: "xml" | "json"; language?: "xml" | "json"
} }
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) { export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
return ( return (
<div className="overflow-hidden w-full"> <div className="overflow-hidden w-full">
<Highlight theme={themes.github} code={code} language={language}> <Highlight theme={themes.github} code={code} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => ( {({
className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre <pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all" className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
style={{ style={{
...style, ...style,
fontFamily: "var(--font-mono), ui-monospace, monospace", fontFamily:
"var(--font-mono), ui-monospace, monospace",
backgroundColor: "transparent", backgroundColor: "transparent",
margin: 0, margin: 0,
padding: 0, padding: 0,
@@ -25,9 +32,16 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
}} }}
> >
{tokens.map((line, i) => ( {tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}> <div
key={i}
{...getLineProps({ line })}
style={{ wordBreak: "break-all" }}
>
{line.map((token, key) => ( {line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} /> <span
key={key}
{...getTokenProps({ token })}
/>
))} ))}
</div> </div>
))} ))}
@@ -35,5 +49,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
)} )}
</Highlight> </Highlight>
</div> </div>
); )
} }

View File

@@ -1,19 +1,19 @@
"use client"; "use client"
import React from "react"; import type React from "react"
interface ErrorToastProps { interface ErrorToastProps {
message: React.ReactNode; message: React.ReactNode
onDismiss: () => void; onDismiss: () => void
} }
export function ErrorToast({ message, onDismiss }: ErrorToastProps) { export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") { if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault(); e.preventDefault()
onDismiss(); onDismiss()
}
} }
};
return ( return (
<div <div
@@ -25,7 +25,12 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors" className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
> >
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
className="w-4 h-4 text-destructive"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
@@ -35,5 +40,5 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
</div> </div>
<span className="text-sm text-foreground">{message}</span> <span className="text-sm text-foreground">{message}</span>
</div> </div>
); )
} }

View File

@@ -1,40 +1,44 @@
"use client"; "use client"
import React, { useEffect, useState } from "react"; import { X } from "lucide-react"
import Image from "next/image"; import Image from "next/image"
import { X } from "lucide-react"; import React, { useEffect, useState } from "react"
interface FilePreviewListProps { interface FilePreviewListProps {
files: File[]; files: File[]
onRemoveFile: (fileToRemove: File) => void; onRemoveFile: (fileToRemove: File) => void
} }
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) { export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null)
// Cleanup object URLs on unmount // Cleanup object URLs on unmount
useEffect(() => { useEffect(() => {
const objectUrls = files const objectUrls = files
.filter((file) => file.type.startsWith("image/")) .filter((file) => file.type.startsWith("image/"))
.map((file) => URL.createObjectURL(file)); .map((file) => URL.createObjectURL(file))
return () => { return () => {
objectUrls.forEach(URL.revokeObjectURL); objectUrls.forEach(URL.revokeObjectURL)
}; }
}, [files]); }, [files])
if (files.length === 0) return null; if (files.length === 0) return null
return ( return (
<> <>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md"> <div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => { {files.map((file, index) => {
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null; const imageUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: null
return ( return (
<div key={file.name + index} className="relative group"> <div key={file.name + index} className="relative group">
<div <div
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer" className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
onClick={() => imageUrl && setSelectedImage(imageUrl)} onClick={() =>
imageUrl && setSelectedImage(imageUrl)
}
> >
{file.type.startsWith("image/") ? ( {file.type.startsWith("image/") ? (
<Image <Image
@@ -59,7 +63,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</div> </div>
); )
})} })}
</div> </div>
@@ -89,5 +93,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
</div> </div>
)} )}
</> </>
); )
} }

View File

@@ -1,6 +1,8 @@
"use client"; "use client"
import { useState } from "react"; import Image from "next/image"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,34 +10,32 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"; import { useDiagram } from "@/contexts/diagram-context"
import Image from "next/image";
import { useDiagram } from "@/contexts/diagram-context";
interface HistoryDialogProps { interface HistoryDialogProps {
showHistory: boolean; showHistory: boolean
onToggleHistory: (show: boolean) => void; onToggleHistory: (show: boolean) => void
} }
export function HistoryDialog({ export function HistoryDialog({
showHistory, showHistory,
onToggleHistory, onToggleHistory,
}: HistoryDialogProps) { }: HistoryDialogProps) {
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram(); const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => { const handleClose = () => {
setSelectedIndex(null); setSelectedIndex(null)
onToggleHistory(false); onToggleHistory(false)
}; }
const handleConfirmRestore = () => { const handleConfirmRestore = () => {
if (selectedIndex !== null) { if (selectedIndex !== null) {
onDisplayChart(diagramHistory[selectedIndex].xml); onDisplayChart(diagramHistory[selectedIndex].xml)
handleClose(); handleClose()
}
} }
};
return ( return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}> <Dialog open={showHistory} onOpenChange={onToggleHistory}>
@@ -100,15 +100,12 @@ export function HistoryDialog({
</Button> </Button>
</> </>
) : ( ) : (
<Button <Button variant="outline" onClick={handleClose}>
variant="outline"
onClick={handleClose}
>
Close Close
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,12 +8,12 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
interface ResetWarningModalProps { interface ResetWarningModalProps {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
onClear: () => void; onClear: () => void
} }
export function ResetWarningModal({ export function ResetWarningModal({
@@ -44,5 +44,5 @@ export function ResetWarningModal({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,36 +1,40 @@
"use client"; "use client"
import { useState, useEffect } from "react"; import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, } from "@/components/ui/dialog"
} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select"
export type ExportFormat = "drawio" | "png" | "svg"; export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [ const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" }, { value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" }, { value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" }, { value: "svg", label: "SVG Image", extension: ".svg" },
]; ]
interface SaveDialogProps { interface SaveDialogProps {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
onSave: (filename: string, format: ExportFormat) => void; onSave: (filename: string, format: ExportFormat) => void
defaultFilename: string; defaultFilename: string
} }
export function SaveDialog({ export function SaveDialog({
@@ -39,29 +43,29 @@ export function SaveDialog({
onSave, onSave,
defaultFilename, defaultFilename,
}: SaveDialogProps) { }: SaveDialogProps) {
const [filename, setFilename] = useState(defaultFilename); const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio"); const [format, setFormat] = useState<ExportFormat>("drawio")
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFilename(defaultFilename); setFilename(defaultFilename)
} }
}, [open, defaultFilename]); }, [open, defaultFilename])
const handleSave = () => { const handleSave = () => {
const finalFilename = filename.trim() || defaultFilename; const finalFilename = filename.trim() || defaultFilename
onSave(finalFilename, format); onSave(finalFilename, format)
onOpenChange(false); onOpenChange(false)
}; }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault()
handleSave(); handleSave()
}
} }
};
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format); const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,13 +76,19 @@ export function SaveDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Format</label> <label className="text-sm font-medium">Format</label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}> <Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FORMAT_OPTIONS.map((opt) => ( {FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem
key={opt.value}
value={opt.value}
>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
@@ -104,12 +114,15 @@ export function SaveDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave}>Save</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,48 +1,46 @@
"use client"; "use client"
import { useState, useEffect } from "react"; import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, } from "@/components/ui/dialog"
DialogDescription, import { Input } from "@/components/ui/input"
} from "@/components/ui/dialog";
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"; export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export function SettingsDialog({ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
open, const [accessCode, setAccessCode] = useState("")
onOpenChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("");
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""; const storedCode =
setAccessCode(storedCode); localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
} }
}, [open]); }, [open])
const handleSave = () => { const handleSave = () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim()); localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false); onOpenChange(false)
}; }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault()
handleSave(); handleSave()
}
} }
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,12 +70,15 @@ export function SettingsDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}>Save</Button> <Button onClick={handleSave}>Save</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }

View File

@@ -1,85 +1,90 @@
"use client"; "use client"
import React, { createContext, useContext, useRef, useState } from "react"; import type React from "react"
import type { DrawIoEmbedRef } from "react-drawio"; import { createContext, useContext, useRef, useState } from "react"
import { extractDiagramXML } from "../lib/utils"; import type { DrawIoEmbedRef } from "react-drawio"
import type { ExportFormat } from "@/components/save-dialog"; import type { ExportFormat } from "@/components/save-dialog"
import { extractDiagramXML } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
chartXML: string; chartXML: string
latestSvg: string; latestSvg: string
diagramHistory: { svg: string; xml: string }[]; diagramHistory: { svg: string; xml: string }[]
loadDiagram: (chart: string) => void; loadDiagram: (chart: string) => void
handleExport: () => void; handleExport: () => void
handleExportWithoutHistory: () => void; handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null>; resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>; drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void; handleDiagramExport: (data: any) => void
clearDiagram: () => void; clearDiagram: () => void
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void; saveDiagramToFile: (
filename: string,
format: ExportFormat,
sessionId?: string,
) => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined); const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
export function DiagramProvider({ children }: { children: React.ReactNode }) { export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>(""); const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>(""); const [latestSvg, setLatestSvg] = useState<string>("")
const [diagramHistory, setDiagramHistory] = useState< const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[] { svg: string; xml: string }[]
>([]); >([])
const drawioRef = useRef<DrawIoEmbedRef | null>(null); const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null); const resolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false); const expectHistoryExportRef = useRef<boolean>(false)
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null; resolver: ((data: string) => void) | null
format: ExportFormat | null; format: ExportFormat | null
}>({ resolver: null, format: null }); }>({ resolver: null, format: null })
const handleExport = () => { const handleExport = () => {
if (drawioRef.current) { if (drawioRef.current) {
// Mark that this export should be saved to history // Mark that this export should be saved to history
expectHistoryExportRef.current = true; expectHistoryExportRef.current = true
drawioRef.current.exportDiagram({ drawioRef.current.exportDiagram({
format: "xmlsvg", format: "xmlsvg",
}); })
}
} }
};
const handleExportWithoutHistory = () => { const handleExportWithoutHistory = () => {
if (drawioRef.current) { if (drawioRef.current) {
// Export without saving to history (for edit_diagram fetching current state) // Export without saving to history (for edit_diagram fetching current state)
drawioRef.current.exportDiagram({ drawioRef.current.exportDiagram({
format: "xmlsvg", format: "xmlsvg",
}); })
}
} }
};
const loadDiagram = (chart: string) => { const loadDiagram = (chart: string) => {
if (drawioRef.current) { if (drawioRef.current) {
drawioRef.current.load({ drawioRef.current.load({
xml: chart, xml: chart,
}); })
}
} }
};
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format; const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data); saveResolverRef.current.resolver(data.data)
saveResolverRef.current = { resolver: null, format: null }; saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail // For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute // Only drawio (which uses xmlsvg internally) has the content attribute
if (format === "png" || format === "svg") { if (format === "png" || format === "svg") {
return; return
} }
} }
const extractedXML = extractDiagramXML(data.data); const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML); setChartXML(extractedXML)
setLatestSvg(data.data); setLatestSvg(data.data)
// Only add to history if this was a user-initiated export // Only add to history if this was a user-initiated export
if (expectHistoryExportRef.current) { if (expectHistoryExportRef.current) {
@@ -89,106 +94,117 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
svg: data.data, svg: data.data,
xml: extractedXML, xml: extractedXML,
}, },
]); ])
expectHistoryExportRef.current = false; expectHistoryExportRef.current = false
} }
if (resolverRef.current) { if (resolverRef.current) {
resolverRef.current(extractedXML); resolverRef.current(extractedXML)
resolverRef.current = null; resolverRef.current = null
}
} }
};
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`; const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
loadDiagram(emptyDiagram); loadDiagram(emptyDiagram)
setChartXML(emptyDiagram); setChartXML(emptyDiagram)
setLatestSvg(""); setLatestSvg("")
setDiagramHistory([]); setDiagramHistory([])
}; }
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => { const saveDiagramToFile = (
filename: string,
format: ExportFormat,
sessionId?: string,
) => {
if (!drawioRef.current) { if (!drawioRef.current) {
console.warn("Draw.io editor not ready"); console.warn("Draw.io editor not ready")
return; return
} }
// Map format to draw.io export format // Map format to draw.io export format
const drawioFormat = format === "drawio" ? "xmlsvg" : format; const drawioFormat = format === "drawio" ? "xmlsvg" : format
// Set up the resolver before triggering export // Set up the resolver before triggering export
saveResolverRef.current = { saveResolverRef.current = {
resolver: (exportData: string) => { resolver: (exportData: string) => {
let fileContent: string | Blob; let fileContent: string | Blob
let mimeType: string; let mimeType: string
let extension: string; let extension: string
if (format === "drawio") { if (format === "drawio") {
// Extract XML from SVG for .drawio format // Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData); const xml = extractDiagramXML(exportData)
let xmlContent = xml; let xmlContent = xml
if (!xml.includes("<mxfile")) { if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`; xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
} }
fileContent = xmlContent; fileContent = xmlContent
mimeType = "application/xml"; mimeType = "application/xml"
extension = ".drawio"; extension = ".drawio"
} else if (format === "png") { } else if (format === "png") {
// PNG data comes as base64 data URL // PNG data comes as base64 data URL
fileContent = exportData; fileContent = exportData
mimeType = "image/png"; mimeType = "image/png"
extension = ".png"; extension = ".png"
} else { } else {
// SVG format // SVG format
fileContent = exportData; fileContent = exportData
mimeType = "image/svg+xml"; mimeType = "image/svg+xml"
extension = ".svg"; extension = ".svg"
} }
// Log save event to Langfuse (flags the trace) // Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId); logSaveToLangfuse(filename, format, sessionId)
// Handle download // Handle download
let url: string; let url: string
if (typeof fileContent === "string" && fileContent.startsWith("data:")) { if (
typeof fileContent === "string" &&
fileContent.startsWith("data:")
) {
// Already a data URL (PNG) // Already a data URL (PNG)
url = fileContent; url = fileContent
} else { } else {
const blob = new Blob([fileContent], { type: mimeType }); const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob); url = URL.createObjectURL(blob)
} }
const a = document.createElement("a"); const a = document.createElement("a")
a.href = url; a.href = url
a.download = `${filename}${extension}`; a.download = `${filename}${extension}`
document.body.appendChild(a); document.body.appendChild(a)
a.click(); a.click()
document.body.removeChild(a); document.body.removeChild(a)
// Delay URL revocation to ensure download completes // Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) { if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100); setTimeout(() => URL.revokeObjectURL(url), 100)
} }
}, },
format, format,
}; }
// Export diagram - callback will be handled in handleDiagramExport // Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ format: drawioFormat }); drawioRef.current.exportDiagram({ format: drawioFormat })
}; }
// Log save event to Langfuse (just flags the trace, doesn't send content) // Log save event to Langfuse (just flags the trace, doesn't send content)
const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => { const logSaveToLangfuse = async (
filename: string,
format: string,
sessionId?: string,
) => {
try { try {
await fetch("/api/log-save", { await fetch("/api/log-save", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename, format, sessionId }), body: JSON.stringify({ filename, format, sessionId }),
}); })
} catch (error) { } catch (error) {
console.warn("Failed to log save to Langfuse:", error); console.warn("Failed to log save to Langfuse:", error)
}
} }
};
return ( return (
<DiagramContext.Provider <DiagramContext.Provider
@@ -208,13 +224,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
> >
{children} {children}
</DiagramContext.Provider> </DiagramContext.Provider>
); )
} }
export function useDiagram() { export function useDiagram() {
const context = useContext(DiagramContext); const context = useContext(DiagramContext)
if (context === undefined) { if (context === undefined) {
throw new Error("useDiagram must be used within a DiagramProvider"); throw new Error("useDiagram must be used within a DiagramProvider")
} }
return context; return context
} }

View File

@@ -1,11 +1,13 @@
import { LangfuseSpanProcessor } from '@langfuse/otel'; import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() { export function register() {
// Skip telemetry if Langfuse env vars are not configured // Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn('[Langfuse] Environment variables not configured - telemetry disabled'); console.warn(
return; "[Langfuse] Environment variables not configured - telemetry disabled",
)
return
} }
const langfuseSpanProcessor = new LangfuseSpanProcessor({ const langfuseSpanProcessor = new LangfuseSpanProcessor({
@@ -14,22 +16,24 @@ export function register() {
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
// Filter out Next.js HTTP request spans so AI SDK spans become root traces // Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => { shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name; const spanName = otelSpan.name
// Skip Next.js HTTP infrastructure spans // Skip Next.js HTTP infrastructure spans
if (spanName.startsWith('POST /') || if (
spanName.startsWith('GET /') || spanName.startsWith("POST /") ||
spanName.includes('BaseServer') || spanName.startsWith("GET /") ||
spanName.includes('handleRequest')) { spanName.includes("BaseServer") ||
return false; spanName.includes("handleRequest")
) {
return false
} }
return true; return true
}, },
}); })
const tracerProvider = new NodeTracerProvider({ const tracerProvider = new NodeTracerProvider({
spanProcessors: [langfuseSpanProcessor], spanProcessors: [langfuseSpanProcessor],
}); })
// Register globally so AI SDK's telemetry also uses this processor // Register globally so AI SDK's telemetry also uses this processor
tracerProvider.register(); tracerProvider.register()
} }

View File

@@ -1,88 +1,88 @@
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createAnthropic } from "@ai-sdk/anthropic"
import { openai, createOpenAI } from '@ai-sdk/openai'; import { azure, createAzure } from "@ai-sdk/azure"
import { createAnthropic } from '@ai-sdk/anthropic'; import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { google, createGoogleGenerativeAI } from '@ai-sdk/google'; import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { azure, createAzure } from '@ai-sdk/azure'; import { createOpenAI, openai } from "@ai-sdk/openai"
import { ollama, createOllama } from 'ollama-ai-provider-v2'; import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { deepseek, createDeepSeek } from '@ai-sdk/deepseek'; import { createOllama, ollama } from "ollama-ai-provider-v2"
export type ProviderName = export type ProviderName =
| 'bedrock' | "bedrock"
| 'openai' | "openai"
| 'anthropic' | "anthropic"
| 'google' | "google"
| 'azure' | "azure"
| 'ollama' | "ollama"
| 'openrouter' | "openrouter"
| 'deepseek'; | "deepseek"
interface ModelConfig { interface ModelConfig {
model: any; model: any
providerOptions?: any; providerOptions?: any
headers?: Record<string, string>; headers?: Record<string, string>
modelId: string; modelId: string
} }
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
const BEDROCK_ANTHROPIC_BETA = { const BEDROCK_ANTHROPIC_BETA = {
bedrock: { bedrock: {
anthropicBeta: ['fine-grained-tool-streaming-2025-05-14'], anthropicBeta: ["fine-grained-tool-streaming-2025-05-14"],
}, },
}; }
// Direct Anthropic API headers for beta features // Direct Anthropic API headers for beta features
const ANTHROPIC_BETA_HEADERS = { const ANTHROPIC_BETA_HEADERS = {
'anthropic-beta': 'fine-grained-tool-streaming-2025-05-14', "anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
}; }
// Map of provider to required environment variable // Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = { const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: 'OPENAI_API_KEY', openai: "OPENAI_API_KEY",
anthropic: 'ANTHROPIC_API_KEY', anthropic: "ANTHROPIC_API_KEY",
google: 'GOOGLE_GENERATIVE_AI_API_KEY', google: "GOOGLE_GENERATIVE_AI_API_KEY",
azure: 'AZURE_API_KEY', azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: 'OPENROUTER_API_KEY', openrouter: "OPENROUTER_API_KEY",
deepseek: 'DEEPSEEK_API_KEY', deepseek: "DEEPSEEK_API_KEY",
}; }
/** /**
* Auto-detect provider based on available API keys * Auto-detect provider based on available API keys
* Returns the provider if exactly one is configured, otherwise null * Returns the provider if exactly one is configured, otherwise null
*/ */
function detectProvider(): ProviderName | null { function detectProvider(): ProviderName | null {
const configuredProviders: ProviderName[] = []; const configuredProviders: ProviderName[] = []
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) { for (const [provider, envVar] of Object.entries(PROVIDER_ENV_VARS)) {
if (envVar === null) { if (envVar === null) {
// Skip ollama - it doesn't require credentials // Skip ollama - it doesn't require credentials
continue; continue
} }
if (process.env[envVar]) { if (process.env[envVar]) {
configuredProviders.push(provider as ProviderName); configuredProviders.push(provider as ProviderName)
} }
} }
if (configuredProviders.length === 1) { if (configuredProviders.length === 1) {
return configuredProviders[0]; return configuredProviders[0]
} }
return null; return null
} }
/** /**
* Validate that required API keys are present for the selected provider * Validate that required API keys are present for the selected provider
*/ */
function validateProviderCredentials(provider: ProviderName): void { function validateProviderCredentials(provider: ProviderName): void {
const requiredVar = PROVIDER_ENV_VARS[provider]; const requiredVar = PROVIDER_ENV_VARS[provider]
if (requiredVar && !process.env[requiredVar]) { if (requiredVar && !process.env[requiredVar]) {
throw new Error( throw new Error(
`${requiredVar} environment variable is required for ${provider} provider. ` + `${requiredVar} environment variable is required for ${provider} provider. ` +
`Please set it in your .env.local file.` `Please set it in your .env.local file.`,
); )
} }
} }
@@ -106,28 +106,28 @@ function validateProviderCredentials(provider: ProviderName): void {
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
*/ */
export function getAIModel(): ModelConfig { export function getAIModel(): ModelConfig {
const modelId = process.env.AI_MODEL; const modelId = process.env.AI_MODEL
if (!modelId) { if (!modelId) {
throw new Error( throw new Error(
`AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5` `AI_MODEL environment variable is required. Example: AI_MODEL=claude-sonnet-4-5`,
); )
} }
// Determine provider: explicit config > auto-detect > error // Determine provider: explicit config > auto-detect > error
let provider: ProviderName; let provider: ProviderName
if (process.env.AI_PROVIDER) { if (process.env.AI_PROVIDER) {
provider = process.env.AI_PROVIDER as ProviderName; provider = process.env.AI_PROVIDER as ProviderName
} else { } else {
const detected = detectProvider(); const detected = detectProvider()
if (detected) { if (detected) {
provider = detected; provider = detected
console.log(`[AI Provider] Auto-detected provider: ${provider}`); console.log(`[AI Provider] Auto-detected provider: ${provider}`)
} else { } else {
// List configured providers for better error message // List configured providers for better error message
const configured = Object.entries(PROVIDER_ENV_VARS) const configured = Object.entries(PROVIDER_ENV_VARS)
.filter(([, envVar]) => envVar && process.env[envVar as string]) .filter(([, envVar]) => envVar && process.env[envVar as string])
.map(([p]) => p); .map(([p]) => p)
if (configured.length === 0) { if (configured.length === 0) {
throw new Error( throw new Error(
@@ -139,125 +139,131 @@ export function getAIModel(): ModelConfig {
`- AWS_ACCESS_KEY_ID for Bedrock\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` + `- OPENROUTER_API_KEY for OpenRouter\n` +
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`Or set AI_PROVIDER=ollama for local Ollama.` `Or set AI_PROVIDER=ollama for local Ollama.`,
); )
} else { } else {
throw new Error( throw new Error(
`Multiple AI providers configured (${configured.join(', ')}). ` + `Multiple AI providers configured (${configured.join(", ")}). ` +
`Please set AI_PROVIDER to specify which one to use.` `Please set AI_PROVIDER to specify which one to use.`,
); )
} }
} }
} }
// Validate provider credentials // Validate provider credentials
validateProviderCredentials(provider); validateProviderCredentials(provider)
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`); console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`)
let model: any; let model: any
let providerOptions: any = undefined; let providerOptions: any
let headers: Record<string, string> | undefined = undefined; let headers: Record<string, string> | undefined
switch (provider) { switch (provider) {
case 'bedrock': { case "bedrock": {
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.) // Use credential provider chain for IAM role support (Amplify, Lambda, etc.)
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev // Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev
const bedrockProvider = createAmazonBedrock({ const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || 'us-west-2', region: process.env.AWS_REGION || "us-west-2",
credentialProvider: fromNodeProviderChain(), credentialProvider: fromNodeProviderChain(),
}); })
model = bedrockProvider(modelId); model = bedrockProvider(modelId)
// Add Anthropic beta options if using Claude models via Bedrock // Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes('anthropic.claude')) { if (modelId.includes("anthropic.claude")) {
providerOptions = BEDROCK_ANTHROPIC_BETA; providerOptions = BEDROCK_ANTHROPIC_BETA
} }
break; break
} }
case 'openai': case "openai":
if (process.env.OPENAI_BASE_URL) { if (process.env.OPENAI_BASE_URL) {
const customOpenAI = createOpenAI({ const customOpenAI = createOpenAI({
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL, baseURL: process.env.OPENAI_BASE_URL,
}); })
model = customOpenAI.chat(modelId); model = customOpenAI.chat(modelId)
} else { } else {
model = openai(modelId); model = openai(modelId)
} }
break; break
case 'anthropic': case "anthropic": {
const customProvider = createAnthropic({ const customProvider = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY, apiKey: process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1', baseURL:
process.env.ANTHROPIC_BASE_URL ||
"https://api.anthropic.com/v1",
headers: ANTHROPIC_BETA_HEADERS, headers: ANTHROPIC_BETA_HEADERS,
}); })
model = customProvider(modelId); model = customProvider(modelId)
// Add beta headers for fine-grained tool streaming // Add beta headers for fine-grained tool streaming
headers = ANTHROPIC_BETA_HEADERS; headers = ANTHROPIC_BETA_HEADERS
break; break
}
case 'google': case "google":
if (process.env.GOOGLE_BASE_URL) { if (process.env.GOOGLE_BASE_URL) {
const customGoogle = createGoogleGenerativeAI({ const customGoogle = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY, apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL, baseURL: process.env.GOOGLE_BASE_URL,
}); })
model = customGoogle(modelId); model = customGoogle(modelId)
} else { } else {
model = google(modelId); model = google(modelId)
} }
break; break
case 'azure': case "azure":
if (process.env.AZURE_BASE_URL) { if (process.env.AZURE_BASE_URL) {
const customAzure = createAzure({ const customAzure = createAzure({
apiKey: process.env.AZURE_API_KEY, apiKey: process.env.AZURE_API_KEY,
baseURL: process.env.AZURE_BASE_URL, baseURL: process.env.AZURE_BASE_URL,
}); })
model = customAzure(modelId); model = customAzure(modelId)
} else { } else {
model = azure(modelId); model = azure(modelId)
} }
break; break
case 'ollama': case "ollama":
if (process.env.OLLAMA_BASE_URL) { if (process.env.OLLAMA_BASE_URL) {
const customOllama = createOllama({ const customOllama = createOllama({
baseURL: process.env.OLLAMA_BASE_URL, baseURL: process.env.OLLAMA_BASE_URL,
}); })
model = customOllama(modelId); model = customOllama(modelId)
} else { } else {
model = ollama(modelId); model = ollama(modelId)
} }
break; break
case 'openrouter': case "openrouter": {
const openrouter = createOpenRouter({ const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY, apiKey: process.env.OPENROUTER_API_KEY,
...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }), ...(process.env.OPENROUTER_BASE_URL && {
}); baseURL: process.env.OPENROUTER_BASE_URL,
model = openrouter(modelId); }),
break; })
model = openrouter(modelId)
break
}
case 'deepseek': case "deepseek":
if (process.env.DEEPSEEK_BASE_URL) { if (process.env.DEEPSEEK_BASE_URL) {
const customDeepSeek = createDeepSeek({ const customDeepSeek = createDeepSeek({
apiKey: process.env.DEEPSEEK_API_KEY, apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL, baseURL: process.env.DEEPSEEK_BASE_URL,
}); })
model = customDeepSeek(modelId); model = customDeepSeek(modelId)
} else { } else {
model = deepseek(modelId); model = deepseek(modelId)
} }
break; break
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek` `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
); )
} }
return { model, providerOptions, headers, modelId }; return { model, providerOptions, headers, modelId }
} }

View File

@@ -1,12 +1,13 @@
export interface CachedResponse { export interface CachedResponse {
promptText: string; promptText: string
hasImage: boolean; hasImage: boolean
xml: string; xml: string
} }
export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Give me a **animated connector** diagram of transformer's architecture", promptText:
"Give me a **animated connector** diagram of transformer's architecture",
hasImage: false, hasImage: false,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
@@ -545,13 +546,16 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</root>`, </root>`,
}, },
]; ]
export function findCachedResponse( export function findCachedResponse(
promptText: string, promptText: string,
hasImage: boolean hasImage: boolean,
): CachedResponse | undefined { ): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find( return CACHED_EXAMPLE_RESPONSES.find(
(c) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== '' (c) =>
); c.promptText === promptText &&
c.hasImage === hasImage &&
c.xml !== "",
)
} }

View File

@@ -1,13 +1,13 @@
import { observe, updateActiveTrace } from '@langfuse/tracing'; import { LangfuseClient } from "@langfuse/client"
import { LangfuseClient } from '@langfuse/client'; import { observe, updateActiveTrace } from "@langfuse/tracing"
import * as api from '@opentelemetry/api'; import * as api from "@opentelemetry/api"
// Singleton LangfuseClient instance for direct API calls // Singleton LangfuseClient instance for direct API calls
let langfuseClient: LangfuseClient | null = null; let langfuseClient: LangfuseClient | null = null
export function getLangfuseClient(): LangfuseClient | null { export function getLangfuseClient(): LangfuseClient | null {
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
return null; return null
} }
if (!langfuseClient) { if (!langfuseClient) {
@@ -15,60 +15,72 @@ export function getLangfuseClient(): LangfuseClient | null {
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
}); })
} }
return langfuseClient; return langfuseClient
} }
// Check if Langfuse is configured // Check if Langfuse is configured
export function isLangfuseEnabled(): boolean { export function isLangfuseEnabled(): boolean {
return !!process.env.LANGFUSE_PUBLIC_KEY; return !!process.env.LANGFUSE_PUBLIC_KEY
} }
// Update trace with input data at the start of request // Update trace with input data at the start of request
export function setTraceInput(params: { export function setTraceInput(params: {
input: string; input: string
sessionId?: string; sessionId?: string
userId?: string; userId?: string
}) { }) {
if (!isLangfuseEnabled()) return; if (!isLangfuseEnabled()) return
updateActiveTrace({ updateActiveTrace({
name: 'chat', name: "chat",
input: params.input, input: params.input,
sessionId: params.sessionId, sessionId: params.sessionId,
userId: params.userId, userId: params.userId,
}); })
} }
// Update trace with output and end the span // Update trace with output and end the span
export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) { export function setTraceOutput(
if (!isLangfuseEnabled()) return; output: string,
usage?: { promptTokens?: number; completionTokens?: number },
) {
if (!isLangfuseEnabled()) return
updateActiveTrace({ output }); updateActiveTrace({ output })
const activeSpan = api.trace.getActiveSpan(); const activeSpan = api.trace.getActiveSpan()
if (activeSpan) { if (activeSpan) {
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them // Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
if (usage?.promptTokens) { if (usage?.promptTokens) {
activeSpan.setAttribute('ai.usage.promptTokens', usage.promptTokens); activeSpan.setAttribute("ai.usage.promptTokens", usage.promptTokens)
activeSpan.setAttribute('gen_ai.usage.input_tokens', usage.promptTokens); activeSpan.setAttribute(
"gen_ai.usage.input_tokens",
usage.promptTokens,
)
} }
if (usage?.completionTokens) { if (usage?.completionTokens) {
activeSpan.setAttribute('ai.usage.completionTokens', usage.completionTokens); activeSpan.setAttribute(
activeSpan.setAttribute('gen_ai.usage.output_tokens', usage.completionTokens); "ai.usage.completionTokens",
usage.completionTokens,
)
activeSpan.setAttribute(
"gen_ai.usage.output_tokens",
usage.completionTokens,
)
} }
activeSpan.end(); activeSpan.end()
} }
} }
// Get telemetry config for streamText // Get telemetry config for streamText
export function getTelemetryConfig(params: { export function getTelemetryConfig(params: {
sessionId?: string; sessionId?: string
userId?: string; userId?: string
}) { }) {
if (!isLangfuseEnabled()) return undefined; if (!isLangfuseEnabled()) return undefined
return { return {
isEnabled: true, isEnabled: true,
@@ -80,16 +92,16 @@ export function getTelemetryConfig(params: {
sessionId: params.sessionId, sessionId: params.sessionId,
userId: params.userId, userId: params.userId,
}, },
}; }
} }
// Wrap a handler with Langfuse observe // Wrap a handler with Langfuse observe
export function wrapWithObserve<T>( export function wrapWithObserve<T>(
handler: (req: Request) => Promise<T> handler: (req: Request) => Promise<T>,
): (req: Request) => Promise<T> { ): (req: Request) => Promise<T> {
if (!isLangfuseEnabled()) { if (!isLangfuseEnabled()) {
return handler; return handler
} }
return observe(handler, { name: 'chat', endOnExit: false }); return observe(handler, { name: "chat", endOnExit: false })
} }

View File

@@ -119,7 +119,7 @@ Common styles:
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex - Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle - Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right - Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
`; `
// Extended system prompt (~4000+ tokens) - for models with 4000 token cache minimum // Extended system prompt (~4000+ tokens) - for models with 4000 token cache minimum
export const EXTENDED_SYSTEM_PROMPT = ` export const EXTENDED_SYSTEM_PROMPT = `
@@ -519,14 +519,14 @@ The XML will be validated before rendering. Ensure:
\`\`\` \`\`\`
Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results. Remember: Quality diagrams communicate clearly. Choose appropriate shapes, use consistent styling, and maintain proper spacing for professional results.
`; `
// Model patterns that require extended prompt (4000 token cache minimum) // Model patterns that require extended prompt (4000 token cache minimum)
// These patterns match Opus 4.5 and Haiku 4.5 model IDs // These patterns match Opus 4.5 and Haiku 4.5 model IDs
const EXTENDED_PROMPT_MODEL_PATTERNS = [ const EXTENDED_PROMPT_MODEL_PATTERNS = [
'claude-opus-4-5', // Matches any Opus 4.5 variant "claude-opus-4-5", // Matches any Opus 4.5 variant
'claude-haiku-4-5', // Matches any Haiku 4.5 variant "claude-haiku-4-5", // Matches any Haiku 4.5 variant
]; ]
/** /**
* Get the appropriate system prompt based on the model ID * Get the appropriate system prompt based on the model ID
@@ -535,16 +535,25 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
* @returns The system prompt string * @returns The system prompt string
*/ */
export function getSystemPrompt(modelId?: string): string { export function getSystemPrompt(modelId?: string): string {
const modelName = modelId || "AI"; const modelName = modelId || "AI"
let prompt: string; let prompt: string
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) { if (
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`); modelId &&
prompt = EXTENDED_SYSTEM_PROMPT; EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) =>
modelId.includes(pattern),
)
) {
console.log(
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
)
prompt = EXTENDED_SYSTEM_PROMPT
} else { } else {
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`); console.log(
prompt = DEFAULT_SYSTEM_PROMPT; `[System Prompt] Using DEFAULT prompt for model: ${modelId || "unknown"}`,
)
prompt = DEFAULT_SYSTEM_PROMPT
} }
return prompt.replace("{{MODEL_NAME}}", modelName); return prompt.replace("{{MODEL_NAME}}", modelName)
} }

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx"
import * as pako from "pako"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import * as pako from 'pako';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -12,45 +12,45 @@ export function cn(...inputs: ClassValue[]) {
* @param indent - The indentation string (default: ' ') * @param indent - The indentation string (default: ' ')
* @returns Formatted XML string * @returns Formatted XML string
*/ */
export function formatXML(xml: string, indent: string = ' '): string { export function formatXML(xml: string, indent: string = " "): string {
let formatted = ''; let formatted = ""
let pad = 0; let pad = 0
// Remove existing whitespace between tags // Remove existing whitespace between tags
xml = xml.replace(/>\s*</g, '><').trim(); xml = xml.replace(/>\s*</g, "><").trim()
// Split on tags // Split on tags
const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean); const tags = xml.split(/(?=<)|(?<=>)/g).filter(Boolean)
tags.forEach((node) => { tags.forEach((node) => {
if (node.match(/^<\/\w/)) { if (node.match(/^<\/\w/)) {
// Closing tag - decrease indent // Closing tag - decrease indent
pad = Math.max(0, pad - 1); pad = Math.max(0, pad - 1)
formatted += indent.repeat(pad) + node + '\n'; formatted += indent.repeat(pad) + node + "\n"
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) { } else if (node.match(/^<\w[^>]*[^/]>.*$/)) {
// Opening tag // Opening tag
formatted += indent.repeat(pad) + node; formatted += indent.repeat(pad) + node
// Only add newline if next item is a tag // Only add newline if next item is a tag
const nextIndex = tags.indexOf(node) + 1; const nextIndex = tags.indexOf(node) + 1
if (nextIndex < tags.length && tags[nextIndex].startsWith('<')) { if (nextIndex < tags.length && tags[nextIndex].startsWith("<")) {
formatted += '\n'; formatted += "\n"
if (!node.match(/^<\w[^>]*\/>$/)) { if (!node.match(/^<\w[^>]*\/>$/)) {
pad++; pad++
} }
} }
} else if (node.match(/^<\w[^>]*\/>$/)) { } else if (node.match(/^<\w[^>]*\/>$/)) {
// Self-closing tag // Self-closing tag
formatted += indent.repeat(pad) + node + '\n'; formatted += indent.repeat(pad) + node + "\n"
} else if (node.startsWith('<')) { } else if (node.startsWith("<")) {
// Other tags (like <?xml) // Other tags (like <?xml)
formatted += indent.repeat(pad) + node + '\n'; formatted += indent.repeat(pad) + node + "\n"
} else { } else {
// Text content // Text content
formatted += node; formatted += node
} }
}); })
return formatted.trim(); return formatted.trim()
} }
/** /**
@@ -63,22 +63,24 @@ export function formatXML(xml: string, indent: string = ' '): string {
export function convertToLegalXml(xmlString: string): string { export function convertToLegalXml(xmlString: string): string {
// This regex will match either self-closing <mxCell .../> or a block element // This regex will match either self-closing <mxCell .../> or a block element
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match. // <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g; const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
let match: RegExpExecArray | null; let match: RegExpExecArray | null
let result = "<root>\n"; let result = "<root>\n"
while ((match = regex.exec(xmlString)) !== null) { while ((match = regex.exec(xmlString)) !== null) {
// match[0] contains the entire matched mxCell block // match[0] contains the entire matched mxCell block
// Indent each line of the matched block for readability. // Indent each line of the matched block for readability.
const formatted = match[0].split('\n').map(line => " " + line.trim()).join('\n'); const formatted = match[0]
result += formatted + "\n"; .split("\n")
.map((line) => " " + line.trim())
.join("\n")
result += formatted + "\n"
} }
result += "</root>"; result += "</root>"
return result; return result
} }
/** /**
* Replace nodes in a Draw.io XML diagram * Replace nodes in a Draw.io XML diagram
* @param currentXML - The original Draw.io XML string * @param currentXML - The original Draw.io XML string
@@ -88,91 +90,96 @@ export function convertToLegalXml(xmlString: string): string {
export function replaceNodes(currentXML: string, nodes: string): string { export function replaceNodes(currentXML: string, nodes: string): string {
// Check for valid inputs // Check for valid inputs
if (!currentXML || !nodes) { if (!currentXML || !nodes) {
throw new Error("Both currentXML and nodes must be provided"); throw new Error("Both currentXML and nodes must be provided")
} }
try { try {
// Parse the XML strings to create DOM objects // Parse the XML strings to create DOM objects
const parser = new DOMParser(); const parser = new DOMParser()
const currentDoc = parser.parseFromString(currentXML, "text/xml"); const currentDoc = parser.parseFromString(currentXML, "text/xml")
// Handle nodes input - if it doesn't contain <root>, wrap it // Handle nodes input - if it doesn't contain <root>, wrap it
let nodesString = nodes; let nodesString = nodes
if (!nodes.includes("<root>")) { if (!nodes.includes("<root>")) {
nodesString = `<root>${nodes}</root>`; nodesString = `<root>${nodes}</root>`
} }
const nodesDoc = parser.parseFromString(nodesString, "text/xml"); const nodesDoc = parser.parseFromString(nodesString, "text/xml")
// Find the root element in the current document // Find the root element in the current document
let currentRoot = currentDoc.querySelector("mxGraphModel > root"); let currentRoot = currentDoc.querySelector("mxGraphModel > root")
if (!currentRoot) { if (!currentRoot) {
// If no root element is found, create the proper structure // If no root element is found, create the proper structure
const mxGraphModel = currentDoc.querySelector("mxGraphModel") || const mxGraphModel =
currentDoc.createElement("mxGraphModel"); currentDoc.querySelector("mxGraphModel") ||
currentDoc.createElement("mxGraphModel")
if (!currentDoc.contains(mxGraphModel)) { if (!currentDoc.contains(mxGraphModel)) {
currentDoc.appendChild(mxGraphModel); currentDoc.appendChild(mxGraphModel)
} }
currentRoot = currentDoc.createElement("root"); currentRoot = currentDoc.createElement("root")
mxGraphModel.appendChild(currentRoot); mxGraphModel.appendChild(currentRoot)
} }
// Find the root element in the nodes document // Find the root element in the nodes document
const nodesRoot = nodesDoc.querySelector("root"); const nodesRoot = nodesDoc.querySelector("root")
if (!nodesRoot) { if (!nodesRoot) {
throw new Error("Invalid nodes: Could not find or create <root> element"); throw new Error(
"Invalid nodes: Could not find or create <root> element",
)
} }
// Clear all existing child elements from the current root // Clear all existing child elements from the current root
while (currentRoot.firstChild) { while (currentRoot.firstChild) {
currentRoot.removeChild(currentRoot.firstChild); currentRoot.removeChild(currentRoot.firstChild)
} }
// Ensure the base cells exist // Ensure the base cells exist
const hasCell0 = Array.from(nodesRoot.childNodes).some( const hasCell0 = Array.from(nodesRoot.childNodes).some(
node => node.nodeName === "mxCell" && (node) =>
(node as Element).getAttribute("id") === "0" node.nodeName === "mxCell" &&
); (node as Element).getAttribute("id") === "0",
)
const hasCell1 = Array.from(nodesRoot.childNodes).some( const hasCell1 = Array.from(nodesRoot.childNodes).some(
node => node.nodeName === "mxCell" && (node) =>
(node as Element).getAttribute("id") === "1" node.nodeName === "mxCell" &&
); (node as Element).getAttribute("id") === "1",
)
// Copy all child nodes from the nodes root to the current root // Copy all child nodes from the nodes root to the current root
Array.from(nodesRoot.childNodes).forEach(node => { Array.from(nodesRoot.childNodes).forEach((node) => {
const importedNode = currentDoc.importNode(node, true); const importedNode = currentDoc.importNode(node, true)
currentRoot.appendChild(importedNode); currentRoot.appendChild(importedNode)
}); })
// Add default cells if they don't exist // Add default cells if they don't exist
if (!hasCell0) { if (!hasCell0) {
const cell0 = currentDoc.createElement("mxCell"); const cell0 = currentDoc.createElement("mxCell")
cell0.setAttribute("id", "0"); cell0.setAttribute("id", "0")
currentRoot.insertBefore(cell0, currentRoot.firstChild); currentRoot.insertBefore(cell0, currentRoot.firstChild)
} }
if (!hasCell1) { if (!hasCell1) {
const cell1 = currentDoc.createElement("mxCell"); const cell1 = currentDoc.createElement("mxCell")
cell1.setAttribute("id", "1"); cell1.setAttribute("id", "1")
cell1.setAttribute("parent", "0"); cell1.setAttribute("parent", "0")
// Insert after cell0 if possible // Insert after cell0 if possible
const cell0 = currentRoot.querySelector('mxCell[id="0"]'); const cell0 = currentRoot.querySelector('mxCell[id="0"]')
if (cell0 && cell0.nextSibling) { if (cell0 && cell0.nextSibling) {
currentRoot.insertBefore(cell1, cell0.nextSibling); currentRoot.insertBefore(cell1, cell0.nextSibling)
} else { } else {
currentRoot.appendChild(cell1); currentRoot.appendChild(cell1)
} }
} }
// Convert the modified DOM back to a string // Convert the modified DOM back to a string
const serializer = new XMLSerializer(); const serializer = new XMLSerializer()
return serializer.serializeToString(currentDoc); return serializer.serializeToString(currentDoc)
} catch (error) { } catch (error) {
throw new Error(`Error replacing nodes: ${error}`); throw new Error(`Error replacing nodes: ${error}`)
} }
} }
@@ -181,30 +188,30 @@ export function replaceNodes(currentXML: string, nodes: string): string {
* Used for attribute-order agnostic comparison * Used for attribute-order agnostic comparison
*/ */
function charCountDict(str: string): Map<string, number> { function charCountDict(str: string): Map<string, number> {
const dict = new Map<string, number>(); const dict = new Map<string, number>()
for (const char of str) { for (const char of str) {
dict.set(char, (dict.get(char) || 0) + 1); dict.set(char, (dict.get(char) || 0) + 1)
} }
return dict; return dict
} }
/** /**
* Compare two strings by character frequency (order-agnostic) * Compare two strings by character frequency (order-agnostic)
*/ */
function sameCharFrequency(a: string, b: string): boolean { function sameCharFrequency(a: string, b: string): boolean {
const trimmedA = a.trim(); const trimmedA = a.trim()
const trimmedB = b.trim(); const trimmedB = b.trim()
if (trimmedA.length !== trimmedB.length) return false; if (trimmedA.length !== trimmedB.length) return false
const dictA = charCountDict(trimmedA); const dictA = charCountDict(trimmedA)
const dictB = charCountDict(trimmedB); const dictB = charCountDict(trimmedB)
if (dictA.size !== dictB.size) return false; if (dictA.size !== dictB.size) return false
for (const [char, count] of dictA) { for (const [char, count] of dictA) {
if (dictB.get(char) !== count) return false; if (dictB.get(char) !== count) return false
} }
return true; return true
} }
/** /**
@@ -215,77 +222,88 @@ function sameCharFrequency(a: string, b: string): boolean {
*/ */
export function replaceXMLParts( export function replaceXMLParts(
xmlContent: string, xmlContent: string,
searchReplacePairs: Array<{ search: string; replace: string }> searchReplacePairs: Array<{ search: string; replace: string }>,
): string { ): string {
// Format the XML first to ensure consistent line breaks // Format the XML first to ensure consistent line breaks
let result = formatXML(xmlContent); let result = formatXML(xmlContent)
let lastProcessedIndex = 0; let lastProcessedIndex = 0
for (const { search, replace } of searchReplacePairs) { for (const { search, replace } of searchReplacePairs) {
// Also format the search content for consistency // Also format the search content for consistency
const formattedSearch = formatXML(search); const formattedSearch = formatXML(search)
const searchLines = formattedSearch.split('\n'); const searchLines = formattedSearch.split("\n")
// Split into lines for exact line matching // Split into lines for exact line matching
const resultLines = result.split('\n'); const resultLines = result.split("\n")
// Remove trailing empty line if exists (from the trailing \n in search content) // Remove trailing empty line if exists (from the trailing \n in search content)
if (searchLines[searchLines.length - 1] === '') { if (searchLines[searchLines.length - 1] === "") {
searchLines.pop(); searchLines.pop()
} }
// Find the line number where lastProcessedIndex falls // Find the line number where lastProcessedIndex falls
let startLineNum = 0; let startLineNum = 0
let currentIndex = 0; let currentIndex = 0
while (currentIndex < lastProcessedIndex && startLineNum < resultLines.length) { while (
currentIndex += resultLines[startLineNum].length + 1; // +1 for \n currentIndex < lastProcessedIndex &&
startLineNum++; startLineNum < resultLines.length
) {
currentIndex += resultLines[startLineNum].length + 1 // +1 for \n
startLineNum++
} }
// Try to find exact match starting from lastProcessedIndex // Try to find exact match starting from lastProcessedIndex
let matchFound = false; let matchFound = false
let matchStartLine = -1; let matchStartLine = -1
let matchEndLine = -1; let matchEndLine = -1
// First try: exact match // First try: exact match
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) { for (
let matches = true; let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) { for (let j = 0; j < searchLines.length; j++) {
if (resultLines[i + j] !== searchLines[j]) { if (resultLines[i + j] !== searchLines[j]) {
matches = false; matches = false
break; break
} }
} }
if (matches) { if (matches) {
matchStartLine = i; matchStartLine = i
matchEndLine = i + searchLines.length; matchEndLine = i + searchLines.length
matchFound = true; matchFound = true
break; break
} }
} }
// Second try: line-trimmed match (fallback) // Second try: line-trimmed match (fallback)
if (!matchFound) { if (!matchFound) {
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) { for (
let matches = true; let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) { for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = resultLines[i + j].trim(); const originalTrimmed = resultLines[i + j].trim()
const searchTrimmed = searchLines[j].trim(); const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) { if (originalTrimmed !== searchTrimmed) {
matches = false; matches = false
break; break
} }
} }
if (matches) { if (matches) {
matchStartLine = i; matchStartLine = i
matchEndLine = i + searchLines.length; matchEndLine = i + searchLines.length
matchFound = true; matchFound = true
break; break
} }
} }
} }
@@ -293,37 +311,46 @@ export function replaceXMLParts(
// Third try: substring match as last resort (for single-line XML) // Third try: substring match as last resort (for single-line XML)
if (!matchFound) { if (!matchFound) {
// Try to find as a substring in the entire content // Try to find as a substring in the entire content
const searchStr = search.trim(); const searchStr = search.trim()
const resultStr = result; const resultStr = result
const index = resultStr.indexOf(searchStr); const index = resultStr.indexOf(searchStr)
if (index !== -1) { if (index !== -1) {
// Found as substring - replace it // Found as substring - replace it
result = resultStr.substring(0, index) + replace.trim() + resultStr.substring(index + searchStr.length); result =
resultStr.substring(0, index) +
replace.trim() +
resultStr.substring(index + searchStr.length)
// Re-format after substring replacement // Re-format after substring replacement
result = formatXML(result); result = formatXML(result)
continue; // Skip the line-based replacement below continue // Skip the line-based replacement below
} }
} }
// Fourth try: character frequency match (attribute-order agnostic) // Fourth try: character frequency match (attribute-order agnostic)
// This handles cases where the model generates XML with different attribute order // This handles cases where the model generates XML with different attribute order
if (!matchFound) { if (!matchFound) {
for (let i = startLineNum; i <= resultLines.length - searchLines.length; i++) { for (
let matches = true; let i = startLineNum;
i <= resultLines.length - searchLines.length;
i++
) {
let matches = true
for (let j = 0; j < searchLines.length; j++) { for (let j = 0; j < searchLines.length; j++) {
if (!sameCharFrequency(resultLines[i + j], searchLines[j])) { if (
matches = false; !sameCharFrequency(resultLines[i + j], searchLines[j])
break; ) {
matches = false
break
} }
} }
if (matches) { if (matches) {
matchStartLine = i; matchStartLine = i
matchEndLine = i + searchLines.length; matchEndLine = i + searchLines.length
matchFound = true; matchFound = true
break; break
} }
} }
} }
@@ -331,70 +358,76 @@ export function replaceXMLParts(
// Fifth try: Match by mxCell id attribute // Fifth try: Match by mxCell id attribute
// Extract id from search pattern and find the element with that id // Extract id from search pattern and find the element with that id
if (!matchFound) { if (!matchFound) {
const idMatch = search.match(/id="([^"]+)"/); const idMatch = search.match(/id="([^"]+)"/)
if (idMatch) { if (idMatch) {
const searchId = idMatch[1]; const searchId = idMatch[1]
// Find lines that contain this id // Find lines that contain this id
for (let i = startLineNum; i < resultLines.length; i++) { for (let i = startLineNum; i < resultLines.length; i++) {
if (resultLines[i].includes(`id="${searchId}"`)) { if (resultLines[i].includes(`id="${searchId}"`)) {
// Found the element with matching id // Found the element with matching id
// Now find the extent of this element (it might span multiple lines) // Now find the extent of this element (it might span multiple lines)
let endLine = i + 1; let endLine = i + 1
const line = resultLines[i].trim(); const line = resultLines[i].trim()
// Check if it's a self-closing tag or has children // Check if it's a self-closing tag or has children
if (!line.endsWith('/>')) { if (!line.endsWith("/>")) {
// Find the closing tag or the end of the mxCell block // Find the closing tag or the end of the mxCell block
let depth = 1; let depth = 1
while (endLine < resultLines.length && depth > 0) { while (endLine < resultLines.length && depth > 0) {
const currentLine = resultLines[endLine].trim(); const currentLine = resultLines[endLine].trim()
if (currentLine.startsWith('<') && !currentLine.startsWith('</') && !currentLine.endsWith('/>')) { if (
depth++; currentLine.startsWith("<") &&
} else if (currentLine.startsWith('</')) { !currentLine.startsWith("</") &&
depth--; !currentLine.endsWith("/>")
) {
depth++
} else if (currentLine.startsWith("</")) {
depth--
} }
endLine++; endLine++
} }
} }
matchStartLine = i; matchStartLine = i
matchEndLine = endLine; matchEndLine = endLine
matchFound = true; matchFound = true
break; break
} }
} }
} }
} }
if (!matchFound) { if (!matchFound) {
throw new Error(`Search pattern not found in the diagram. The pattern may not exist in the current structure.`); throw new Error(
`Search pattern not found in the diagram. The pattern may not exist in the current structure.`,
)
} }
// Replace the matched lines // Replace the matched lines
const replaceLines = replace.split('\n'); const replaceLines = replace.split("\n")
// Remove trailing empty line if exists // Remove trailing empty line if exists
if (replaceLines[replaceLines.length - 1] === '') { if (replaceLines[replaceLines.length - 1] === "") {
replaceLines.pop(); replaceLines.pop()
} }
// Perform the replacement // Perform the replacement
const newResultLines = [ const newResultLines = [
...resultLines.slice(0, matchStartLine), ...resultLines.slice(0, matchStartLine),
...replaceLines, ...replaceLines,
...resultLines.slice(matchEndLine) ...resultLines.slice(matchEndLine),
]; ]
result = newResultLines.join('\n'); result = newResultLines.join("\n")
// Update lastProcessedIndex to the position after the replacement // Update lastProcessedIndex to the position after the replacement
lastProcessedIndex = 0; lastProcessedIndex = 0
for (let i = 0; i < matchStartLine + replaceLines.length; i++) { for (let i = 0; i < matchStartLine + replaceLines.length; i++) {
lastProcessedIndex += newResultLines[i].length + 1; lastProcessedIndex += newResultLines[i].length + 1
} }
} }
return result; return result
} }
/** /**
@@ -403,167 +436,173 @@ export function replaceXMLParts(
* @returns null if valid, error message string if invalid * @returns null if valid, error message string if invalid
*/ */
export function validateMxCellStructure(xml: string): string | null { export function validateMxCellStructure(xml: string): string | null {
const parser = new DOMParser(); const parser = new DOMParser()
const doc = parser.parseFromString(xml, "text/xml"); const doc = parser.parseFromString(xml, "text/xml")
// Check for XML parsing errors (includes unescaped special characters) // Check for XML parsing errors (includes unescaped special characters)
const parseError = doc.querySelector('parsererror'); const parseError = doc.querySelector("parsererror")
if (parseError) { if (parseError) {
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.`; return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use &lt; for <, &gt; for >, &amp; for &, &quot; for ". Regenerate the diagram with properly escaped values.`
} }
// Get all mxCell elements once for all validations // Get all mxCell elements once for all validations
const allCells = doc.querySelectorAll('mxCell'); const allCells = doc.querySelectorAll("mxCell")
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents // Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
const cellIds = new Set<string>(); const cellIds = new Set<string>()
const duplicateIds: string[] = []; const duplicateIds: string[] = []
const nestedCells: string[] = []; const nestedCells: string[] = []
const orphanCells: string[] = []; const orphanCells: string[] = []
const invalidParents: { id: string; parent: string }[] = []; const invalidParents: { id: string; parent: string }[] = []
const edgesToValidate: { id: string; source: string | null; target: string | null }[] = []; const edgesToValidate: {
id: string
source: string | null
target: string | null
}[] = []
allCells.forEach(cell => { allCells.forEach((cell) => {
const id = cell.getAttribute('id'); const id = cell.getAttribute("id")
const parent = cell.getAttribute('parent'); const parent = cell.getAttribute("parent")
const isEdge = cell.getAttribute('edge') === '1'; const isEdge = cell.getAttribute("edge") === "1"
// Check for duplicate IDs // Check for duplicate IDs
if (id) { if (id) {
if (cellIds.has(id)) { if (cellIds.has(id)) {
duplicateIds.push(id); duplicateIds.push(id)
} else { } else {
cellIds.add(id); cellIds.add(id)
} }
} }
// Check for nested mxCell (parent element is also mxCell) // Check for nested mxCell (parent element is also mxCell)
if (cell.parentElement?.tagName === 'mxCell') { if (cell.parentElement?.tagName === "mxCell") {
nestedCells.push(id || 'unknown'); nestedCells.push(id || "unknown")
} }
// Check parent attribute (skip root cell id="0") // Check parent attribute (skip root cell id="0")
if (id !== '0') { if (id !== "0") {
if (!parent) { if (!parent) {
if (id) orphanCells.push(id); if (id) orphanCells.push(id)
} else { } else {
// Store for later validation (after all IDs collected) // Store for later validation (after all IDs collected)
invalidParents.push({ id: id || 'unknown', parent }); invalidParents.push({ id: id || "unknown", parent })
} }
} }
// Collect edges for connection validation // Collect edges for connection validation
if (isEdge) { if (isEdge) {
edgesToValidate.push({ edgesToValidate.push({
id: id || 'unknown', id: id || "unknown",
source: cell.getAttribute('source'), source: cell.getAttribute("source"),
target: cell.getAttribute('target') target: cell.getAttribute("target"),
}); })
} }
}); })
// Return errors in priority order // Return errors in priority order
if (nestedCells.length > 0) { if (nestedCells.length > 0) {
return `Invalid XML: Found nested mxCell elements (IDs: ${nestedCells.slice(0, 3).join(', ')}). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure.`; return `Invalid XML: Found nested mxCell elements (IDs: ${nestedCells.slice(0, 3).join(", ")}). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure.`
} }
if (duplicateIds.length > 0) { if (duplicateIds.length > 0) {
return `Invalid XML: Found duplicate cell IDs (${duplicateIds.slice(0, 3).join(', ')}). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements.`; return `Invalid XML: Found duplicate cell IDs (${duplicateIds.slice(0, 3).join(", ")}). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements.`
} }
if (orphanCells.length > 0) { if (orphanCells.length > 0) {
return `Invalid XML: Found cells without parent attribute (IDs: ${orphanCells.slice(0, 3).join(', ')}). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references.`; return `Invalid XML: Found cells without parent attribute (IDs: ${orphanCells.slice(0, 3).join(", ")}). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references.`
} }
// Validate parent references (now that all IDs are collected) // Validate parent references (now that all IDs are collected)
const badParents = invalidParents.filter(p => !cellIds.has(p.parent)); const badParents = invalidParents.filter((p) => !cellIds.has(p.parent))
if (badParents.length > 0) { if (badParents.length > 0) {
const details = badParents.slice(0, 3).map(p => `${p.id} (parent: ${p.parent})`).join(', '); const details = badParents
return `Invalid XML: Found cells with invalid parent references (${details}). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references.`; .slice(0, 3)
.map((p) => `${p.id} (parent: ${p.parent})`)
.join(", ")
return `Invalid XML: Found cells with invalid parent references (${details}). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references.`
} }
// Validate edge connections // Validate edge connections
const invalidConnections: string[] = []; const invalidConnections: string[] = []
edgesToValidate.forEach(edge => { edgesToValidate.forEach((edge) => {
if (edge.source && !cellIds.has(edge.source)) { if (edge.source && !cellIds.has(edge.source)) {
invalidConnections.push(`${edge.id} (source: ${edge.source})`); invalidConnections.push(`${edge.id} (source: ${edge.source})`)
} }
if (edge.target && !cellIds.has(edge.target)) { if (edge.target && !cellIds.has(edge.target)) {
invalidConnections.push(`${edge.id} (target: ${edge.target})`); invalidConnections.push(`${edge.id} (target: ${edge.target})`)
} }
}); })
if (invalidConnections.length > 0) { if (invalidConnections.length > 0) {
return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(', ')}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`; return `Invalid XML: Found edges with invalid source/target references (${invalidConnections.slice(0, 3).join(", ")}). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections.`
} }
return null; return null
} }
export function extractDiagramXML(xml_svg_string: string): string { export function extractDiagramXML(xml_svg_string: string): string {
try { try {
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment) // 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
const svgString = atob(xml_svg_string.slice(26)); const svgString = atob(xml_svg_string.slice(26))
const parser = new DOMParser(); const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgString, "image/svg+xml"); const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
const svgElement = svgDoc.querySelector('svg'); const svgElement = svgDoc.querySelector("svg")
if (!svgElement) { if (!svgElement) {
throw new Error("No SVG element found in the input string."); throw new Error("No SVG element found in the input string.")
} }
// 2. Extract the 'content' attribute // 2. Extract the 'content' attribute
const encodedContent = svgElement.getAttribute('content'); const encodedContent = svgElement.getAttribute("content")
if (!encodedContent) { if (!encodedContent) {
throw new Error("SVG element does not have a 'content' attribute."); throw new Error("SVG element does not have a 'content' attribute.")
} }
// 3. Decode HTML entities (using a minimal function) // 3. Decode HTML entities (using a minimal function)
function decodeHtmlEntities(str: string) { function decodeHtmlEntities(str: string) {
const textarea = document.createElement('textarea'); // Use built-in element const textarea = document.createElement("textarea") // Use built-in element
textarea.innerHTML = str; textarea.innerHTML = str
return textarea.value; return textarea.value
} }
const xmlContent = decodeHtmlEntities(encodedContent); const xmlContent = decodeHtmlEntities(encodedContent)
// 4. Parse the XML content // 4. Parse the XML content
const xmlDoc = parser.parseFromString(xmlContent, "text/xml"); const xmlDoc = parser.parseFromString(xmlContent, "text/xml")
const diagramElement = xmlDoc.querySelector('diagram'); const diagramElement = xmlDoc.querySelector("diagram")
if (!diagramElement) { if (!diagramElement) {
throw new Error("No diagram element found"); throw new Error("No diagram element found")
} }
// 5. Extract base64 encoded data // 5. Extract base64 encoded data
const base64EncodedData = diagramElement.textContent; const base64EncodedData = diagramElement.textContent
if (!base64EncodedData) { if (!base64EncodedData) {
throw new Error("No encoded data found in the diagram element"); throw new Error("No encoded data found in the diagram element")
} }
// 6. Decode base64 data // 6. Decode base64 data
const binaryString = atob(base64EncodedData); const binaryString = atob(base64EncodedData)
// 7. Convert binary string to Uint8Array // 7. Convert binary string to Uint8Array
const len = binaryString.length; const len = binaryString.length
const bytes = new Uint8Array(len); const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i); bytes[i] = binaryString.charCodeAt(i)
} }
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15) // 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
const decompressedData = pako.inflate(bytes, { windowBits: -15 }); const decompressedData = pako.inflate(bytes, { windowBits: -15 })
// 9. Convert the decompressed data to a string // 9. Convert the decompressed data to a string
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder("utf-8")
const decodedString = decoder.decode(decompressedData); const decodedString = decoder.decode(decompressedData)
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote) // Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
const urlDecodedString = decodeURIComponent(decodedString); const urlDecodedString = decodeURIComponent(decodedString)
return urlDecodedString;
return urlDecodedString
} catch (error) { } catch (error) {
console.error("Error extracting diagram XML:", error); console.error("Error extracting diagram XML:", error)
throw error; // Re-throw for caller handling throw error // Re-throw for caller handling
} }
} }

View File

@@ -1,8 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
output: 'standalone', output: "standalone",
}; }
export default nextConfig; export default nextConfig

610
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",
@@ -61,6 +62,8 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-next": "16.0.5", "eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
@@ -1452,6 +1455,169 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@biomejs/biome": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.8.tgz",
"integrity": "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.8",
"@biomejs/cli-darwin-x64": "2.3.8",
"@biomejs/cli-linux-arm64": "2.3.8",
"@biomejs/cli-linux-arm64-musl": "2.3.8",
"@biomejs/cli-linux-x64": "2.3.8",
"@biomejs/cli-linux-x64-musl": "2.3.8",
"@biomejs/cli-win32-arm64": "2.3.8",
"@biomejs/cli-win32-x64": "2.3.8"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.8.tgz",
"integrity": "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.8.tgz",
"integrity": "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.8.tgz",
"integrity": "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.8.tgz",
"integrity": "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.8.tgz",
"integrity": "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.8.tgz",
"integrity": "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.8.tgz",
"integrity": "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.8.tgz",
"integrity": "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
@@ -5678,6 +5844,35 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-escapes": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
"integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -6203,6 +6398,39 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^7.1.0",
"string-width": "^8.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -6238,6 +6466,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6260,6 +6495,16 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6588,6 +6833,19 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.0", "version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -7250,6 +7508,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true,
"license": "MIT"
},
"node_modules/eventsource-parser": { "node_modules/eventsource-parser": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
@@ -7499,6 +7764,19 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -7841,6 +8119,22 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8116,6 +8410,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-generator-function": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -8829,6 +9139,49 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lint-staged": {
"version": "16.2.7",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.2",
"listr2": "^9.0.5",
"micromatch": "^4.0.8",
"nano-spawn": "^2.0.0",
"pidtree": "^0.6.0",
"string-argv": "^0.3.2",
"yaml": "^2.8.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8852,6 +9205,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/long": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -9794,6 +10167,19 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -9832,6 +10218,19 @@
"mustache": "bin/mustache" "mustache": "bin/mustache"
} }
}, },
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -10122,6 +10521,22 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10292,6 +10707,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -10748,6 +11176,23 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -10759,6 +11204,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rrweb-cssom": { "node_modules/rrweb-cssom": {
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
@@ -11074,6 +11526,49 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/slice-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -11124,6 +11619,33 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -11251,6 +11773,22 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-bom": { "node_modules/strip-bom": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -12167,6 +12705,62 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
@@ -12210,6 +12804,22 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -7,7 +7,10 @@
"dev": "next dev --turbopack --port 6002", "dev": "next dev --turbopack --port 6002",
"build": "next build", "build": "next build",
"start": "next start --port 6001", "start": "next start --port 6001",
"lint": "next lint" "lint": "biome lint .",
"format": "biome check --write .",
"check": "biome ci",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.62", "@ai-sdk/amazon-bedrock": "^3.0.62",
@@ -53,7 +56,14 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"lint-staged": {
"*.{js,ts,jsx,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched",
"biome check --no-errors-on-unmatched"
]
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.8",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/node": "^20", "@types/node": "^20",
@@ -62,6 +72,8 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "9.39.1", "eslint": "9.39.1",
"eslint-config-next": "16.0.5", "eslint-config-next": "16.0.5",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ["@tailwindcss/postcss"],
}; }
export default config; export default config

View File

@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -23,9 +19,7 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": [
@@ -35,7 +29,5 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules"]
"node_modules"
]
} }