Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
30bf92fbbb feat: enhance system prompt with app context and dynamic model name
- Add App Context section describing the left/right panel layout
- Add App Features section with icon locations (history, theme, upload, export, clear)
- Dynamically inject model name into system prompt via {{MODEL_NAME}} placeholder
- Expand edit_diagram tool description with usage guidelines
2025-12-06 12:36:57 +09:00
41 changed files with 2740 additions and 4134 deletions

View File

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

View File

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

23
.vscode/settings.json vendored
View File

@@ -1,23 +0,0 @@
{
"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,14 +1,13 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import Image from "next/image" import Link from "next/link";
import Link from "next/link" import { FaGithub } from "react-icons/fa";
import { FaGithub } from "react-icons/fa" import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "关于 - Next AI Draw.io", title: "关于 - Next AI Draw.io",
description: description: "AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
"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 (
@@ -17,23 +16,14 @@ 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 <Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
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 <Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link> </Link>
<Link <Link href="/about/cn" className="text-blue-600 font-semibold">
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -55,41 +45,22 @@ 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"> <h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
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 <Link href="/about" className="text-gray-600 hover:text-blue-600">English</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 <Link href="/about/cn" className="text-blue-600 font-semibold"></Link>
href="/about/cn"
className="text-blue-600 font-semibold"
>
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link <Link href="/about/ja" className="text-gray-600 hover:text-blue-600"></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 Opus 4.5 Claude Haiku 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -98,167 +69,80 @@ export default function AboutCN() {
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
</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> <li><strong>LLM驱动的图表创建</strong>draw.io图表</li>
<strong>LLM驱动的图表创建</strong> <li><strong></strong>AI自动复制和增强</li>
draw.io图表 <li><strong></strong>AI编辑前的图表版本</li>
</li> <li><strong></strong>AI实时对话来完善您的图表</li>
<li> <li><strong>AWS架构图支持</strong>AWS架构图</li>
<strong></strong> <li><strong></strong></li>
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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
<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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Transformer连接器</h3>
Transformer连接器
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong> <strong></strong> <strong></strong>Transformer架构图
<strong></strong>Transformer架构图
</p> </p>
<Image <Image src="/animated_connectors.svg" alt="带动画连接器的Transformer架构" width={480} height={360} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">GCP架构图</h3>
GCP架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong></strong> 使<strong>GCP图标</strong>GCP架构图
<strong>GCP图标</strong>
GCP架构图
</p> </p>
<Image <Image src="/gcp_demo.svg" alt="GCP架构图" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">AWS架构图</h3>
AWS架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong></strong> 使<strong>AWS图标</strong>AWS架构图
<strong>AWS图标</strong>
AWS架构图
</p> </p>
<Image <Image src="/aws_demo.svg" alt="AWS架构图" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Azure架构图</h3>
Azure架构图
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong> 使 <strong></strong> 使<strong>Azure图标</strong>Azure架构图
<strong>Azure图标</strong>
Azure架构图
</p> </p>
<Image <Image src="/azure_demo.svg" alt="Azure架构图" width={400} height={300} className="mx-auto" />
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 className="text-lg font-semibold text-gray-900 mb-2"></h3>
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong></strong>
</p> </p>
<Image <Image src="/cat_demo.svg" alt="猫咪绘图" width={240} height={240} className="mx-auto" />
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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
</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> <li><strong>Next.js</strong></li>
<strong>Next.js</strong> <li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>用于流式AI响应和多提供商支持</li>
</li> <li><strong>react-drawio</strong>:用于图表表示和操作</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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
</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> <li>OpenAI / OpenAI兼容API <code>OPENAI_BASE_URL</code></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>
@@ -267,15 +151,12 @@ 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>{" "} <code>claude-sonnet-4-5</code> AWS标志的draw.io图表上进行训练AWS架构图
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 className="text-2xl font-semibold text-gray-900"></h2>
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -286,24 +167,14 @@ export default function AboutCN() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
{" "} {" "}
<a <a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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 <a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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
@@ -330,5 +201,5 @@ export default function AboutCN() {
</div> </div>
</footer> </footer>
</div> </div>
) );
} }

View File

@@ -1,21 +1,13 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import Image from "next/image" import Link from "next/link";
import Link from "next/link" import { FaGithub } from "react-icons/fa";
import { FaGithub } from "react-icons/fa" import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "概要 - Next AI Draw.io", title: "概要 - Next AI Draw.io",
description: description: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。", keywords: ["AIダイアグラム", "draw.io", "AWSアーキテクチャ", "GCPダイアグラム", "Azureダイアグラム", "LLM"],
keywords: [ };
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
}
export default function AboutJA() { export default function AboutJA() {
return ( return (
@@ -24,23 +16,14 @@ 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 <Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
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 <Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
</Link> </Link>
<Link <Link href="/about/ja" className="text-blue-600 font-semibold">
href="/about/ja"
className="text-blue-600 font-semibold"
>
</Link> </Link>
<a <a
@@ -62,43 +45,22 @@ 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"> <h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
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 <Link href="/about" className="text-gray-600 hover:text-blue-600">English</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 <Link href="/about/cn" className="text-gray-600 hover:text-blue-600"></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 <Link href="/about/ja" className="text-blue-600 font-semibold"></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 Claude Opus 4.5 Claude Haiku 4.5
Opus 4.5
Claude Haiku 4.5
</p> </p>
</div> </div>
@@ -107,176 +69,80 @@ export default function AboutJA() {
</p> </p>
{/* Features */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
</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> <li><strong>LLM搭載のダイアグラム作成</strong>draw.ioダイアグラムを作成</li>
<strong>LLM搭載のダイアグラム作成</strong> <li><strong></strong>AIが自動的に複製</li>
draw.ioダイアグラムを作成 <li><strong></strong>AI編集前のダイアグラムの以前のバージョンを表示</li>
</li> <li><strong></strong>AIとリアルタイムでコミュニケーションしてダイアグラムを改善</li>
<li> <li><strong>AWSアーキテクチャダイアグラムサポート</strong>AWSアーキテクチャダイアグラムの生成を専門的にサポート</li>
<strong></strong> <li><strong></strong></li>
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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
<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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Transformerコネクタ</h3>
Transformerコネクタ
</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong></strong>{" "} <strong></strong> <strong></strong>Transformerアーキテクチャ図を作成してください
<strong></strong>
Transformerアーキテクチャ図を作成してください
</p> </p>
<Image <Image src="/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width={480} height={360} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">GCPアーキテクチャ図</h3>
GCPアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong></strong> <strong>GCPアイコン</strong>使GCPアーキテクチャ図を生成してください
<strong>GCPアイコン</strong>
使GCPアーキテクチャ図を生成してください
</p> </p>
<Image <Image src="/gcp_demo.svg" alt="GCPアーキテクチャ図" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">AWSアーキテクチャ図</h3>
AWSアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong></strong> <strong>AWSアイコン</strong>使AWSアーキテクチャ図を生成してください
<strong>AWSアイコン</strong>
使AWSアーキテクチャ図を生成してください
</p> </p>
<Image <Image src="/aws_demo.svg" alt="AWSアーキテクチャ図" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Azureアーキテクチャ図</h3>
Azureアーキテクチャ図
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong></strong> <strong>Azureアイコン</strong>使Azureアーキテクチャ図を生成してください
<strong>Azureアイコン</strong>
使Azureアーキテクチャ図を生成してください
</p> </p>
<Image <Image src="/azure_demo.svg" alt="Azureアーキテクチャ図" width={400} height={300} className="mx-auto" />
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 className="text-lg font-semibold text-gray-900 mb-2"></h3>
</h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong></strong>{" "} <strong></strong>
</p> </p>
<Image <Image src="/cat_demo.svg" alt="猫の絵" width={240} height={240} className="mx-auto" />
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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
<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> <li><strong>Next.js</strong></li>
<strong>Next.js</strong> <li><strong>Vercel AI SDK</strong><code>ai</code> + <code>@ai-sdk/*</code>ストリーミングAIレスポンスとマルチプロバイダーサポート</li>
<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 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"></h2>
</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> <li>OpenAI / OpenAI互換API<code>OPENAI_BASE_URL</code></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>
@@ -285,15 +151,12 @@ 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> <code>claude-sonnet-4-5</code>AWSロゴ付きのdraw.ioダイアグラムで学習されているためAWSアーキテクチャダイアグラムを作成したい場合は最適な選択です
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 className="text-2xl font-semibold text-gray-900"></h2>
</h2>
<iframe <iframe
src="https://github.com/sponsors/DayuanJiang/button" src="https://github.com/sponsors/DayuanJiang/button"
title="Sponsor DayuanJiang" title="Sponsor DayuanJiang"
@@ -304,24 +167,14 @@ export default function AboutJA() {
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">
{" "} {" "}
<a <a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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 <a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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
@@ -343,11 +196,10 @@ 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 - Next AI Draw.io - AI搭載ダイアグラムジェネレーター
AI搭載ダイアグラムジェネレーター
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
) );
} }

View File

@@ -1,21 +1,13 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import Image from "next/image" import Link from "next/link";
import Link from "next/link" import { FaGithub } from "react-icons/fa";
import { FaGithub } from "react-icons/fa" import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "About - Next AI Draw.io", title: "About - Next AI Draw.io",
description: description: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
"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"],
keywords: [ };
"AI diagram",
"draw.io",
"AWS architecture",
"GCP diagram",
"Azure diagram",
"LLM",
],
}
export default function About() { export default function About() {
return ( return (
@@ -24,23 +16,14 @@ 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 <Link href="/" className="text-xl font-bold text-gray-900 hover:text-gray-700">
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 <Link href="/" className="text-gray-600 hover:text-gray-900 transition-colors">
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Editor Editor
</Link> </Link>
<Link <Link href="/about" className="text-blue-600 font-semibold">
href="/about"
className="text-blue-600 font-semibold"
>
About About
</Link> </Link>
<a <a
@@ -62,236 +45,105 @@ 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"> <h1 className="text-4xl font-bold text-gray-900 mb-2">Next AI Draw.io</h1>
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, AI-Powered Diagram Creation Tool - Chat, Draw, Visualize
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 <Link href="/about" className="text-blue-600 font-semibold">English</Link>
href="/about"
className="text-blue-600 font-semibold"
>
English
</Link>
<span className="text-gray-400">|</span> <span className="text-gray-400">|</span>
<Link <Link href="/about/cn" className="text-gray-600 hover:text-blue-600"></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 <Link href="/about/ja" className="text-gray-600 hover:text-blue-600"></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 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.
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 A Next.js web application that integrates AI capabilities with draw.io diagrams.
capabilities with draw.io diagrams. Create, modify, and Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
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"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Features</h2>
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> <li><strong>LLM-Powered Diagram Creation</strong>: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands</li>
<strong>LLM-Powered Diagram Creation</strong>: <li><strong>Image-Based Diagram Replication</strong>: Upload existing diagrams or images and have the AI replicate and enhance them automatically</li>
Leverage Large Language Models to create and <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>
manipulate draw.io diagrams directly through natural <li><strong>Interactive Chat Interface</strong>: Communicate with AI to refine your diagrams in real-time</li>
language commands <li><strong>AWS Architecture Diagram Support</strong>: Specialized support for generating AWS architecture diagrams</li>
</li> <li><strong>Animated Connectors</strong>: Create dynamic and animated connectors between diagram elements for better visualization</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"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Examples</h2>
Examples <p className="text-gray-700 mb-6">Here are some example prompts and their generated diagrams:</p>
</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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Animated Transformer Connectors</h3>
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>Prompt:</strong> Give me an <strong>animated connector</strong> diagram of transformer&apos;s architecture.
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
</p> </p>
<Image <Image src="/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width={480} height={360} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">GCP Architecture Diagram</h3>
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 <strong>Prompt:</strong> Generate a GCP architecture diagram with <strong>GCP icons</strong>. Users connect to a frontend hosted on an instance.
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
</p> </p>
<Image <Image src="/gcp_demo.svg" alt="GCP Architecture Diagram" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">AWS Architecture Diagram</h3>
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 <strong>Prompt:</strong> Generate an AWS architecture diagram with <strong>AWS icons</strong>. Users connect to a frontend hosted on an instance.
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
</p> </p>
<Image <Image src="/aws_demo.svg" alt="AWS Architecture Diagram" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Azure Architecture Diagram</h3>
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 <strong>Prompt:</strong> Generate an Azure architecture diagram with <strong>Azure icons</strong>. Users connect to a frontend hosted on an instance.
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
</p> </p>
<Image <Image src="/azure_demo.svg" alt="Azure Architecture Diagram" width={400} height={300} className="mx-auto" />
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"> <h3 className="text-lg font-semibold text-gray-900 mb-2">Cat Sketch</h3>
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 <strong>Prompt:</strong> Draw a cute cat for me.
me.
</p> </p>
<Image <Image src="/cat_demo.svg" alt="Cat Drawing" width={240} height={240} className="mx-auto" />
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"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">How It Works</h2>
How It Works <p className="text-gray-700 mb-4">The application uses the following technologies:</p>
</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> <li><strong>Next.js</strong>: For the frontend framework and routing</li>
<strong>Next.js</strong>: For the frontend framework <li><strong>Vercel AI SDK</strong> (<code>ai</code> + <code>@ai-sdk/*</code>): For streaming AI responses and multi-provider support</li>
and routing <li><strong>react-drawio</strong>: For diagram representation and manipulation</li>
</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 Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
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"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">Multi-Provider Support</h2>
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> <li>OpenAI / OpenAI-compatible APIs (via <code>OPENAI_BASE_URL</code>)</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>
@@ -300,17 +152,12 @@ 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 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.
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"> <h2 className="text-2xl font-semibold text-gray-900">Support &amp; Contact</h2>
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"
@@ -321,24 +168,14 @@ 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 <a href="https://github.com/sponsors/DayuanJiang" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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 <a href="https://github.com/DayuanJiang/next-ai-draw-io" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
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
@@ -360,11 +197,10 @@ 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 Next AI Draw.io - Open Source AI-Powered Diagram Generator
Generator
</p> </p>
</div> </div>
</footer> </footer>
</div> </div>
) );
} }

View File

@@ -1,299 +1,247 @@
import { import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
convertToModelMessages, import { getAIModel } from '@/lib/ai-providers';
createUIMessageStream, import { findCachedResponse } from '@/lib/cached-responses';
createUIMessageStreamResponse, import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse';
streamText, import { getSystemPrompt } from '@/lib/system-prompts';
} from "ai" import { z } from "zod";
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[]): { function validateFileParts(messages: any[]): { valid: boolean; error?: string } {
valid: boolean const lastMessage = messages[messages.length - 1];
error?: string const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || [];
} {
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 { return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` };
valid: false, }
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith('data:')) {
const base64Data = filePart.url.split(',')[1];
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4);
if (sizeInBytes > MAX_FILE_SIZE) {
return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` };
} }
}
} }
}
for (const filePart of fileParts) { return { valid: true };
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
} }
// Helper function to check if diagram is minimal/empty // 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({ writer.write({ type: 'tool-input-start', toolCallId, toolName: 'display_diagram' });
type: "tool-input-start", writer.write({ type: 'tool-input-delta', toolCallId, inputTextDelta: xml });
toolCallId, writer.write({ type: 'tool-input-available', toolCallId, toolName: 'display_diagram', input: { xml } });
toolName: "display_diagram", writer.write({ type: 'finish' });
}) },
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 = const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
process.env.ACCESS_CODE_LIST?.split(",") if (accessCodes.length > 0) {
.map((code) => code.trim()) const accessCodeHeader = req.headers.get('x-access-code');
.filter(Boolean) || [] if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
if (accessCodes.length > 0) { return Response.json(
const accessCodeHeader = req.headers.get("x-access-code") { error: 'Invalid or missing access code. Please configure it in Settings.' },
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) { { status: 401 }
return Response.json( );
{
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 = const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200
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 = const userInputText = currentMessage?.parts?.find((p: any) => p.type === 'text')?.text || '';
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 ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1;
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0];
const textPart = lastMessage.parts?.find((p: any) => p.type === 'text');
const filePart = lastMessage.parts?.find((p: any) => p.type === 'file');
const cached = findCachedResponse(textPart?.text || '', !!filePart);
if (cached) {
console.log('[Cache] Returning cached response for:', textPart?.text);
return createCachedStreamResponse(cached.xml);
} }
// === FILE VALIDATION END === }
// === CACHE CHECK END ===
// === CACHE CHECK START === // Get AI model from environment configuration
const isFirstMessage = messages.length === 1 const { model, providerOptions, headers, modelId } = getAIModel();
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
if (isFirstMessage && isEmptyDiagram) { // Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const lastMessage = messages[0] const systemMessage = getSystemPrompt(modelId);
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart) const lastMessage = messages[messages.length - 1];
if (cached) { // Extract text from the last message parts
console.log( const lastMessageText = lastMessage.parts?.find((part: any) => part.type === 'text')?.text || '';
"[Cache] Returning cached response for:",
textPart?.text,
)
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Get AI model from environment configuration // Extract file parts (images) from the last message
const { model, providerOptions, headers, modelId } = getAIModel() const fileParts = lastMessage.parts?.filter((part: any) => part.type === 'file') || [];
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // User input only - XML is now in a separate cached system message
const systemMessage = getSystemPrompt(modelId) const formattedUserInput = `User input:
const lastMessage = messages[messages.length - 1]
// Extract text from the last message parts
const lastMessageText =
lastMessage.parts?.find((part: any) => part.type === "text")?.text || ""
// Extract file parts (images) from the last message
const fileParts =
lastMessage.parts?.filter((part: any) => part.type === "file") || []
// User input only - XML is now in a separate cached system message
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( let enhancedMessages = modelMessages.filter((msg: any) =>
(msg: any) => msg.content && Array.isArray(msg.content) && msg.content.length > 0
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 }
] ];
}
} }
}
// Add cache point to the last assistant message in conversation history // Add cache point to the last assistant message in conversation history
// This caches the entire conversation prefix for subsequent requests // This caches the entire conversation prefix for subsequent requests
// Strategy: system (cached) + history with last assistant (cached) + new user message // Strategy: system (cached) + history with last assistant (cached) + new user message
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
} }
}
} }
}
// System messages with multiple cache breakpoints for optimal caching: // System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes // - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn // - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// This allows: if only user message changes, both system caches are reused // This allows: if only user message changes, both system caches are reused
// if XML changes, instruction cache is still reused // if XML changes, instruction cache is still reused
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,
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), ...(providerOptions && { providerOptions }),
...(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({ experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }),
sessionId: validSessionId, }),
userId, onFinish: ({ text, usage, providerMetadata }) => {
}), console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
}), console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
onFinish: ({ text, usage, providerMetadata }) => { // Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
console.log( // AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
"[Cache] Full providerMetadata:", setTraceOutput(text, {
JSON.stringify(providerMetadata, null, 2), promptTokens: usage?.inputTokens,
) completionTokens: usage?.outputTokens,
console.log("[Cache] Usage:", JSON.stringify(usage, null, 2)) });
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry) },
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens tools: {
setTraceOutput(text, { // Client-side tool that will be executed on the client
promptTokens: usage?.inputTokens, display_diagram: {
completionTokens: usage?.outputTokens, description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
})
},
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
description: `Display a diagram on draw.io. Pass the XML content inside <root> tags.
VALIDATION RULES (XML will be rejected if violated): VALIDATION RULES (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested 1. All mxCell elements must be DIRECT children of <root> - never nested
@@ -328,63 +276,64 @@ Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
- 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 xml: z.string().describe("XML string to be displayed on draw.io")
.string() })
.describe("XML string to be displayed on draw.io"), },
}), 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.
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.
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
IMPORTANT: Keep edits concise:
- COPY the exact mxCell line from the current XML (attribute order matters!)
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
- Break large changes into multiple smaller edits
- Each search must contain complete lines (never truncate mid-line)
- First match only - be specific enough to target the right element`,
inputSchema: z.object({
edits: z
.array(
z.object({
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,
})
return result.toUIMessageStreamResponse() WHEN TO USE:
- Changing text labels or values
- Modifying colors, styles, or visual properties
- Adding or removing individual elements (1-3 elements)
- Repositioning specific elements
- Any small, targeted modification
WHEN TO USE display_diagram INSTEAD:
- Creating a new diagram from scratch
- Major restructuring (reorganizing layout, changing diagram type)
- Adding many new elements (more than 3)
- After 3 failed edit_diagram attempts
CRITICAL RULES:
1. Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
2. Do NOT reorder attributes - attribute order in draw.io XML varies, you MUST match exactly
3. Always include the element's id attribute for unique targeting
4. Include complete lines (never truncate mid-line)
5. For multiple changes, use separate edits in the array
ERROR RECOVERY:
- If pattern not found, check attribute order matches current XML exactly
- Retry up to 3 times with adjusted patterns
- After 3 failures, use display_diagram instead`,
inputSchema: z.object({
edits: z.array(z.object({
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,
});
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( return Response.json({ error: 'Internal server error' }, { status: 500 });
{ 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,12 +1,9 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
export async function GET() { export async function GET() {
const accessCodes = const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
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,112 +1,103 @@
import { randomUUID } from "crypto" import { getLangfuseClient } from '@/lib/langfuse';
import { z } from "zod" import { randomUUID } from 'crypto';
import { getLangfuseClient } from "@/lib/langfuse" import { z } from 'zod';
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
let data;
try {
data = feedbackSchema.parse(await req.json());
} catch {
return Response.json({ success: false, error: 'Invalid input' }, { status: 400 });
}
const { messageId, feedback, sessionId } = data;
// Get user IP for tracking
const forwardedFor = req.headers.get('x-forwarded-for');
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
try {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
});
const traces = tracesResponse.data || [];
const latestTrace = traces[0];
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID();
const timestamp = new Date().toISOString();
await langfuse.api.ingestion.batch({
batch: [
{
type: 'trace-create',
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: 'user-feedback',
sessionId,
userId,
input: { messageId, feedback },
metadata: { source: 'feedback-button', note: 'standalone - no chat trace found' },
timestamp,
},
},
{
type: 'score-create',
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: 'user-feedback',
value: feedback === 'good' ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
});
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString();
await langfuse.api.ingestion.batch({
batch: [
{
type: 'score-create',
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: 'user-feedback',
value: feedback === 'good' ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
});
} }
// Validate input return Response.json({ success: true, logged: true });
let data } catch (error) {
try { console.error('Langfuse feedback error:', error);
data = feedbackSchema.parse(await req.json()) return Response.json({ success: false, error: 'Failed to log feedback' }, { status: 500 });
} catch { }
return Response.json(
{ success: false, error: "Invalid input" },
{ status: 400 },
)
}
const { messageId, feedback, sessionId } = data
// Get user IP for tracking
const forwardedFor = req.headers.get("x-forwarded-for")
const userId = forwardedFor?.split(",")[0]?.trim() || "anonymous"
try {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
sessionId,
limit: 1,
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID()
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "trace-create",
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: "user-feedback",
sessionId,
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
timestamp,
},
},
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
}
return Response.json({ success: true, logged: true })
} catch (error) {
console.error("Langfuse feedback error:", error)
return Response.json(
{ success: false, error: "Failed to log feedback" },
{ status: 500 },
)
}
} }

View File

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

View File

@@ -6,254 +6,250 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
} }
:root { :root {
--radius: 0.75rem; --radius: 0.75rem;
/* Clean Light Modern Palette */ /* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240); --background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260); --foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260); --card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260); --popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */ /* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260); --primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0); --primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */ /* Warm gray secondary */
--secondary: oklch(0.96 0.005 260); --secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260); --secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */ /* Light muted tones */
--muted: oklch(0.965 0.005 260); --muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.5 0.02 260); --muted-foreground: oklch(0.50 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.6 0.2 25); --destructive: oklch(0.60 0.20 25);
/* Subtle borders */ /* Subtle borders */
--border: oklch(0.92 0.01 260); --border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260); --input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260); --ring: oklch(0.25 0.01 260);
/* 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.7 0.18 45); --chart-3: oklch(0.70 0.18 45);
--chart-4: oklch(0.6 0.2 330); --chart-4: oklch(0.60 0.20 330);
--chart-5: oklch(0.5 0.15 200); --chart-5: oklch(0.50 0.15 200);
/* Sidebar */ /* Sidebar */
--sidebar: oklch(0.99 0.002 260); --sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260); --sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265); --sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270); --sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265); --sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260); --sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265); --sidebar-ring: oklch(0.55 0.18 265);
} }
.dark { .dark {
--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.2 0.015 260); --card: oklch(0.20 0.015 260);
--card-foreground: oklch(0.95 0.01 260); --card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.2 0.015 260); --popover: oklch(0.20 0.015 260);
--popover-foreground: oklch(0.95 0.01 260); --popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.7 0.16 265); --primary: oklch(0.70 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.9 0.01 260); --secondary-foreground: oklch(0.90 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.3 0.04 280); --accent: oklch(0.30 0.04 280);
--accent-foreground: oklch(0.9 0.03 270); --accent-foreground: oklch(0.90 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.7 0.16 265); --ring: oklch(0.70 0.16 265);
--chart-1: oklch(0.7 0.16 265); --chart-1: oklch(0.70 0.16 265);
--chart-2: oklch(0.7 0.13 170); --chart-2: oklch(0.70 0.13 170);
--chart-3: oklch(0.75 0.16 45); --chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.7 0.18 330); --chart-4: oklch(0.70 0.18 330);
--chart-5: oklch(0.6 0.13 200); --chart-5: oklch(0.60 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.7 0.16 265); --sidebar-primary: oklch(0.70 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.9 0.02 265); --sidebar-accent-foreground: oklch(0.90 0.02 265);
--sidebar-border: oklch(0.28 0.015 260); --sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.7 0.16 265); --sidebar-ring: oklch(0.70 0.16 265);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground font-sans; @apply bg-background text-foreground font-sans;
} }
} }
/* Fix for Radix ScrollArea viewport horizontal overflow */ /* Fix for Radix ScrollArea viewport horizontal overflow */
[data-slot="scroll-area-viewport"] > div { [data-slot="scroll-area-viewport"] > div {
display: block !important; display: block !important;
width: 100% !important; width: 100% !important;
} }
/* Custom scrollbar */ /* Custom scrollbar */
@layer utilities { @layer utilities {
.scrollbar-thin { .scrollbar-thin {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 260) transparent; scrollbar-color: oklch(0.85 0.01 260) transparent;
} }
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
width: 6px; width: 6px;
} }
.scrollbar-thin::-webkit-scrollbar-track { .scrollbar-thin::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scrollbar-thin::-webkit-scrollbar-thumb { .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.85 0.01 260); background-color: oklch(0.85 0.01 260);
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260); background-color: oklch(0.75 0.01 260);
} }
} }
/* Smooth page transitions */ /* Smooth page transitions */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes slideInRight { @keyframes slideInRight {
from { from {
opacity: 0; opacity: 0;
transform: translateX(16px); transform: translateX(16px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
} }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.3s ease-out forwards; animation: fadeIn 0.3s ease-out forwards;
} }
.animate-slide-in-right { .animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards; animation: slideInRight 0.3s ease-out forwards;
} }
/* Message bubble animations */ /* Message bubble animations */
@keyframes messageIn { @keyframes messageIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(12px) scale(0.98); transform: translateY(12px) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
} }
} }
.animate-message-in { .animate-message-in {
animation: messageIn 0.25s ease-out forwards; animation: messageIn 0.25s ease-out forwards;
} }
/* Subtle floating shadow for cards */ /* Subtle floating shadow for cards */
.shadow-soft { .shadow-soft {
box-shadow: box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04), 0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06), 0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04); 0 8px 24px oklch(0.23 0.02 260 / 0.04);
} }
.shadow-soft-lg { .shadow-soft-lg {
box-shadow: box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04), 0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08), 0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06); 0 16px 40px oklch(0.23 0.02 260 / 0.06);
} }
/* Gradient text utility */ /* Gradient text utility */
.text-gradient-primary { .text-gradient-primary {
background: linear-gradient( background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290));
135deg, -webkit-background-clip: text;
oklch(0.55 0.18 265), -webkit-text-fill-color: transparent;
oklch(0.6 0.2 290) background-clip: text;
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }

View File

@@ -1,53 +1,41 @@
import { GoogleAnalytics } from "@next/third-parties/google" import type { Metadata, Viewport } from "next";
import { Analytics } from "@vercel/analytics/react" import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next" import { Analytics } from "@vercel/analytics/react";
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google" import { GoogleAnalytics } from "@next/third-parties/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: description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
"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"],
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: description: "Create professional diagrams with AI assistance. Supports AWS architecture, flowcharts, and more.",
"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",
@@ -64,8 +52,7 @@ 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: description: "Create professional diagrams with AI assistance. Free, no login required.",
"Create professional diagrams with AI assistance. Free, no login required.",
images: ["/architecture.png"], images: ["/architecture.png"],
}, },
robots: { robots: {
@@ -82,28 +69,27 @@ 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: 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.',
"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',
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">
@@ -123,5 +109,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,79 +1,75 @@
"use client" "use client";
import React, { useEffect, useRef, useState } from "react" import React, { useState, useEffect, useRef } from "react";
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio";
import type { ImperativePanelHandle } from "react-resizable-panels" import ChatPanel from "@/components/chat-panel";
import ChatPanel from "@/components/chat-panel" import { useDiagram } from "@/contexts/diagram-context";
import { import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" ResizablePanel,
import { useDiagram } from "@/contexts/diagram-context" ResizableHandle,
} from "@/components/ui/resizable";
import type { ImperativePanelHandle } from "react-resizable-panels";
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">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">(() => {
const [isThemeLoaded, setIsThemeLoaded] = useState(false) if (typeof window !== "undefined") {
const saved = localStorage.getItem("drawio-theme");
// Load theme from localStorage after mount to avoid hydration mismatch if (saved === "min" || saved === "sketch") return saved;
useEffect(() => {
const saved = localStorage.getItem("drawio-theme")
if (saved === "min" || saved === "sketch") {
setDrawioUi(saved)
} }
setIsThemeLoaded(true) 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">
@@ -84,30 +80,20 @@ 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 <div className={`h-full relative ${isMobile ? "p-1" : "p-2"}`}>
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">
{isThemeLoaded ? ( <DrawIoEmbed
<DrawIoEmbed key={drawioUi}
key={drawioUi} ref={drawioRef}
ref={drawioRef} onExport={handleDiagramExport}
onExport={handleDiagramExport} urlParameters={{
urlParameters={{ ui: drawioUi,
ui: drawioUi, spin: true,
spin: true, libraries: false,
libraries: false, saveAndExit: false,
saveAndExit: false, noExitBtn: true,
noExitBtn: true, }}
}} />
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
</div> </div>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -131,10 +117,9 @@ export default function Home() {
onToggleVisibility={toggleChatPanel} onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi} drawioUi={drawioUi}
onToggleDrawioUi={() => { onToggleDrawioUi={() => {
const newTheme = const newTheme = drawioUi === "min" ? "sketch" : "min";
drawioUi === "min" ? "sketch" : "min" localStorage.setItem("drawio-theme", newTheme);
localStorage.setItem("drawio-theme", newTheme) setDrawioUi(newTheme);
setDrawioUi(newTheme)
}} }}
isMobile={isMobile} isMobile={isMobile}
/> />
@@ -142,5 +127,5 @@ export default function Home() {
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</div> </div>
) );
} }

View File

@@ -1,12 +1,12 @@
import type { MetadataRoute } from "next" import { 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 type { MetadataRoute } from "next" import { 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,
}, },
] ]

View File

@@ -1,59 +0,0 @@
{
"$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,21 +1,21 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york", "style": "new-york",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "app/globals.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide" "iconLibrary": "lucide"
} }

View File

@@ -1,19 +1,19 @@
import type { VariantProps } from "class-variance-authority" import React from "react";
import type React from "react" import { Button, buttonVariants } from "@/components/ui/button";
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,10 +27,8 @@ 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 className="max-w-xs text-wrap">{tooltipContent}</TooltipContent>
{tooltipContent}
</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) );
} }

View File

@@ -1,12 +1,12 @@
"use client" "use client";
import { Cloud, GitBranch, Palette, Zap } from "lucide-react" import { Zap, Cloud, GitBranch, Palette } 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,8 +75,7 @@ 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 Describe what you want to create or upload an image to replicate
replicate
</p> </p>
</div> </div>
@@ -92,9 +91,7 @@ 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( setInput("Give me a **animated connector** diagram of transformer's architecture")
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([]) setFiles([])
}} }}
/> />
@@ -129,5 +126,5 @@ export default function ExamplePanel({
</p> </p>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,24 +1,10 @@
"use client" "use client";
import { import React, { useCallback, useRef, useEffect, useState } from "react";
Download, import { Button } from "@/components/ui/button";
History, import { Textarea } from "@/components/ui/textarea";
Image as ImageIcon, import { ResetWarningModal } from "@/components/reset-warning-modal";
LayoutGrid, import { SaveDialog } from "@/components/save-dialog";
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,
@@ -26,105 +12,103 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea" import {
import { useDiagram } from "@/contexts/diagram-context" Loader2,
import { FilePreviewList } from "./file-preview-list" Send,
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) => ( (t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} /> { duration: 5000 }
), );
{ duration: 5000 },
)
} }
interface ValidationResult { interface ValidationResult {
validFiles: File[] validFiles: File[];
errors: string[] errors: string[];
} }
function validateFiles( function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
newFiles: File[], const errors: string[] = [];
existingCount: number, const validFiles: File[] = [];
): 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( errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
`"${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( showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
<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"> <span className="font-medium">{errors.length} files rejected:</span>
{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) => ( {errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
<li key={i}>{err}</li> {errors.length > 3 && <li>...and {errors.length - 3} more</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({
@@ -142,133 +126,128 @@ 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 = ( const imageFiles = (await Promise.all(
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( const { validFiles, errors } = validateFiles(imageFiles, files.length);
imageFiles, showValidationErrors(errors);
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
@@ -337,11 +316,7 @@ export function ChatInput({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowThemeWarning(true)} onClick={() => setShowThemeWarning(true)}
tooltipContent={ tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"}
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" ? (
@@ -351,33 +326,27 @@ export function ChatInput({
)} )}
</ButtonWithTooltip> </ButtonWithTooltip>
<Dialog <Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}>
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle> <DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription> <DialogDescription>
Switching themes will reload the diagram Switching themes will reload the diagram editor and clear any unsaved changes.
editor and clear any unsaved changes.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => onClick={() => setShowThemeWarning(false)}
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
@@ -470,5 +439,5 @@ export function ChatInput({
</div> </div>
</div> </div>
</form> </form>
) );
} }

View File

@@ -1,45 +1,25 @@
"use client" "use client";
import type { UIMessage } from "ai" import { useRef, useEffect, useState, useCallback } from "react";
import { import Image from "next/image";
Check, import ReactMarkdown from "react-markdown";
ChevronDown, import { ScrollArea } from "@/components/ui/scroll-area";
ChevronUp, import ExamplePanel from "./chat-example-panel";
Copy, import { UIMessage } from "ai";
Cpu, import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
Minus, import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react";
Pencil, import { CodeBlock } from "./code-block";
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 <div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50">
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}
@@ -50,9 +30,7 @@ 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"> <span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span>
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}
@@ -62,9 +40,7 @@ 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"> <span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span>
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}
@@ -74,26 +50,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({
@@ -104,47 +80,43 @@ 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< const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
string | null const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({});
>(null) const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({}) const [editText, setEditText] = useState<string>("");
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", {
@@ -155,52 +127,49 @@ 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( console.log("[ChatMessageDisplay] XML validation failed:", validationError);
"[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 (
@@ -211,44 +180,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
@@ -296,16 +265,10 @@ 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" && ) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} /> <EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && ) : typeof input === "object" && Object.keys(input).length > 0 ? (
Object.keys(input).length > 0 ? ( <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null} ) : null}
</div> </div>
)} )}
@@ -315,8 +278,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">
@@ -325,122 +288,72 @@ 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 = const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
message.role === "user" const isLastAssistantMessage = message.role === "assistant" && (
? getMessageTextContent(message) messageIndex === messages.length - 1 ||
: "" messages.slice(messageIndex + 1).every(m => m.role !== "assistant")
const isLastAssistantMessage = );
message.role === "assistant" && const isLastUserMessage = message.role === "user" && (
(messageIndex === messages.length - 1 || messageIndex === messages.length - 1 ||
messages messages.slice(messageIndex + 1).every(m => m.role !== "user")
.slice(messageIndex + 1) );
.every((m) => m.role !== "assistant")) const isEditing = editingMessageId === message.id;
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={{ style={{ animationDelay: `${messageIndex * 50}ms` }}
animationDelay: `${messageIndex * 50}ms`,
}}
> >
{message.role === "user" && {message.role === "user" && userMessageText && !isEditing && (
userMessageText && <div className="flex items-center gap-1 self-center mr-2">
!isEditing && ( {/* Edit button - only on last user message */}
<div className="flex items-center gap-1 self-center mr-2"> {onEditMessage && isLastUserMessage && (
{/* Edit button - only on last user message */}
{onEditMessage &&
isLastUserMessage && (
<button
onClick={() => {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button <button
onClick={() => onClick={() => {
copyMessageToClipboard( setEditingMessageId(message.id);
message.id, setEditText(userMessageText);
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={ title="Edit message"
copiedMessageId ===
message.id
? "Copied!"
: copyFailedMessageId ===
message.id
? "Failed to copy"
: "Copy message"
}
> >
{copiedMessageId === <Pencil className="h-3.5 w-3.5" />
message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId ===
message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button> </button>
</div> )}
)} <button
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
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"}
>
{copiedMessageId === message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId === message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
<div className="max-w-[85%] min-w-0"> <div className="max-w-[85%] min-w-0">
{/* Edit mode for user messages */} {/* Edit mode for user messages */}
{isEditing && message.role === "user" ? ( {isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<textarea <textarea
value={editText} value={editText}
onChange={(e) => onChange={(e) => setEditText(e.target.value)}
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( rows={Math.min(editText.split('\n').length + 1, 6)}
editText.split("\n")
.length + 1,
6,
)}
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingMessageId( setEditingMessageId(null);
null, setEditText("");
) } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
setEditText("") e.preventDefault();
} else if ( if (editText.trim() && onEditMessage) {
e.key === "Enter" && onEditMessage(messageIndex, editText.trim());
(e.metaKey || e.ctrlKey) setEditingMessageId(null);
) { setEditText("");
e.preventDefault()
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
} }
}} }}
@@ -448,10 +361,8 @@ export function ChatMessageDisplay({
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
onClick={() => { onClick={() => {
setEditingMessageId( setEditingMessageId(null);
null, setEditText("");
)
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"
> >
@@ -459,18 +370,10 @@ export function ChatMessageDisplay({
</button> </button>
<button <button
onClick={() => { onClick={() => {
if ( if (editText.trim() && onEditMessage) {
editText.trim() && onEditMessage(messageIndex, editText.trim());
onEditMessage setEditingMessageId(null);
) { setEditText("");
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
} }
}} }}
disabled={!editText.trim()} disabled={!editText.trim()}
@@ -482,172 +385,100 @@ export function ChatMessageDisplay({
</div> </div>
) : ( ) : (
/* Text content in bubble */ /* Text content in bubble */
message.parts?.some( message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
(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 === : message.role === "system"
"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 ( if (message.role === "user" && isLastUserMessage && onEditMessage) {
message.role === setEditingMessageId(message.id);
"user" && setEditText(userMessageText);
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
} }
}} }}
title={ title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined}
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "Click to edit"
: undefined
}
> >
{message.parts?.map( {message.parts?.map((part: any, index: number) => {
( switch (part.type) {
part: any, case "text":
index: number, return (
) => { <div key={index} className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
switch (part.type) { message.role === "user"
case "text": ? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
return ( : "dark:prose-invert"
<div }`}>
key={ <ReactMarkdown>{part.text}</ReactMarkdown>
index </div>
} );
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${ case "file":
message.role === return (
"user" <div key={index} className="mt-2">
? "[&_*]:!text-primary-foreground prose-code:bg-white/20" <Image
: "dark:prose-invert" src={part.url}
}`} width={200}
> height={200}
<ReactMarkdown> alt={`Uploaded diagram or image for AI analysis`}
{ className="rounded-lg border border-white/20"
part.text style={{
} objectFit: "contain",
</ReactMarkdown> }}
</div> />
) </div>
case "file": );
return ( default:
<div return null;
key={ }
index })}
}
className="mt-2"
>
<Image
src={
part.url
}
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit:
"contain",
}}
/>
</div>
)
default:
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={() => onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))}
copyMessageToClipboard(
message.id,
getMessageTextContent(
message,
),
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId === copiedMessageId === message.id
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={ title={copiedMessageId === message.id ? "Copied!" : "Copy response"}
copiedMessageId ===
message.id
? "Copied!"
: "Copy response"
}
> >
{copiedMessageId === {copiedMessageId === message.id ? (
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 && {onRegenerate && isLastAssistantMessage && (
isLastAssistantMessage && ( <button
<button onClick={() => onRegenerate(messageIndex)}
onClick={() => className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
onRegenerate( title="Regenerate response"
messageIndex, >
) <RotateCcw className="h-3.5 w-3.5" />
} </button>
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors" )}
title="Regenerate response"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{/* Divider */} {/* Divider */}
<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={() => onClick={() => submitFeedback(message.id, "good")}
submitFeedback(
message.id,
"good",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === feedback[message.id] === "good"
"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"
}`} }`}
@@ -657,15 +488,9 @@ export function ChatMessageDisplay({
</button> </button>
{/* Thumbs down */} {/* Thumbs down */}
<button <button
onClick={() => onClick={() => submitFeedback(message.id, "bad")}
submitFeedback(
message.id,
"bad",
)
}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === feedback[message.id] === "bad"
"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"
}`} }`}
@@ -677,11 +502,11 @@ export function ChatMessageDisplay({
)} )}
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ScrollArea> </ScrollArea>
) );
} }

View File

@@ -1,36 +1,37 @@
"use client" "use client";
import { useChat } from "@ai-sdk/react" import type React from "react";
import { DefaultChatTransport } from "ai" import { useRef, useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa";
import { import {
CheckCircle,
PanelRightClose, PanelRightClose,
PanelRightOpen, PanelRightOpen,
Settings, Settings,
} from "lucide-react" CheckCircle,
import Image from "next/image" } from "lucide-react";
import Link from "next/link" import Link from "next/link";
import type React from "react" import Image from "next/image";
import { useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom" import { useChat } from "@ai-sdk/react";
import { FaGithub } from "react-icons/fa" import { DefaultChatTransport } from "ai";
import { Toaster } from "sonner" import { ChatInput } from "@/components/chat-input";
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ChatMessageDisplay } from "./chat-message-display";
import { ChatInput } from "@/components/chat-input" import { useDiagram } from "@/contexts/diagram-context";
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({
@@ -47,61 +48,59 @@ 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( new Error("Chart export timed out after 10 seconds")
"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({
@@ -110,71 +109,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",
@@ -187,14 +186,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
@@ -204,63 +203,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 },
{ {
@@ -271,78 +270,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(
@@ -352,54 +351,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(
@@ -409,9 +408,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) {
@@ -436,7 +435,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
@@ -448,9 +447,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
style={{ position: "absolute" }} style={{ position: "absolute" }}
/> />
{/* Header */} {/* Header */}
<header <header className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}>
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -461,9 +458,7 @@ 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 <h1 className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}>
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
>
Next AI Drawio Next AI Drawio
</h1> </h1>
</div> </div>
@@ -493,9 +488,7 @@ 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 <FaGithub className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a> </a>
{accessCodeRequired && ( {accessCodeRequired && (
<ButtonWithTooltip <ButtonWithTooltip
@@ -505,9 +498,7 @@ 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 <Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip> </ButtonWithTooltip>
)} )}
{!isMobile && ( {!isMobile && (
@@ -538,23 +529,21 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</main> </main>
{/* Input */} {/* Input */}
<footer <footer className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}>
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}
@@ -572,5 +561,5 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onOpenChange={setShowSettingsDialog} onOpenChange={setShowSettingsDialog}
/> />
</div> </div>
) );
} }

View File

@@ -1,29 +1,22 @@
"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: fontFamily: "var(--font-mono), ui-monospace, monospace",
"var(--font-mono), ui-monospace, monospace",
backgroundColor: "transparent", backgroundColor: "transparent",
margin: 0, margin: 0,
padding: 0, padding: 0,
@@ -32,16 +25,9 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
}} }}
> >
{tokens.map((line, i) => ( {tokens.map((line, i) => (
<div <div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
key={i}
{...getLineProps({ line })}
style={{ wordBreak: "break-all" }}
>
{line.map((token, key) => ( {line.map((token, key) => (
<span <span key={key} {...getTokenProps({ token })} />
key={key}
{...getTokenProps({ token })}
/>
))} ))}
</div> </div>
))} ))}
@@ -49,5 +35,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
)} )}
</Highlight> </Highlight>
</div> </div>
) );
} }

View File

@@ -1,19 +1,19 @@
"use client" "use client";
import type React from "react" import 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,12 +25,7 @@ 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 <svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
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"
@@ -40,5 +35,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,44 +1,40 @@
"use client" "use client";
import { X } from "lucide-react" import React, { useEffect, useState } from "react";
import Image from "next/image" import Image from "next/image";
import React, { useEffect, useState } from "react" import { X } from "lucide-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/") const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
? 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={() => onClick={() => imageUrl && setSelectedImage(imageUrl)}
imageUrl && setSelectedImage(imageUrl)
}
> >
{file.type.startsWith("image/") ? ( {file.type.startsWith("image/") ? (
<Image <Image
@@ -63,7 +59,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>
@@ -93,5 +89,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
</div> </div>
)} )}
</> </>
) );
} }

View File

@@ -1,8 +1,6 @@
"use client" "use client";
import Image from "next/image" import { useState } from "react";
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,32 +8,34 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { useDiagram } from "@/contexts/diagram-context" import { Button } from "@/components/ui/button";
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,12 +100,15 @@ export function HistoryDialog({
</Button> </Button>
</> </>
) : ( ) : (
<Button variant="outline" onClick={handleClose}> <Button
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,40 +1,36 @@
"use client" "use client";
import { useEffect, useState } from "react" import { useState, useEffect } 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,
} from "@/components/ui/dialog" DialogFooter,
import { Input } from "@/components/ui/input" } from "@/components/ui/dialog";
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: { const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [
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({
@@ -43,29 +39,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}>
@@ -76,19 +72,13 @@ 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 <Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
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 <SelectItem key={opt.value} value={opt.value}>
key={opt.value}
value={opt.value}
>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
@@ -114,15 +104,12 @@ export function SaveDialog({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)}>
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,46 +1,48 @@
"use client" "use client";
import { useEffect, useState } from "react" import { useState, useEffect } 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,
} from "@/components/ui/dialog" DialogFooter,
import { Input } from "@/components/ui/input" DialogDescription,
} 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({ open, onOpenChange }: SettingsDialogProps) { export function SettingsDialog({
const [accessCode, setAccessCode] = useState("") open,
onOpenChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("");
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const storedCode = const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "" setAccessCode(storedCode);
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}>
@@ -70,15 +72,12 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)}>
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,90 +1,85 @@
"use client" "use client";
import type React from "react" import React, { createContext, useContext, useRef, useState } from "react";
import { createContext, useContext, useRef, useState } from "react" import type { DrawIoEmbedRef } from "react-drawio";
import type { DrawIoEmbedRef } from "react-drawio" import { extractDiagramXML } from "../lib/utils";
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: ( saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void;
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) {
@@ -94,117 +89,106 @@ 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 = ( const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => {
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 ( if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
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 ( const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => {
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
@@ -224,13 +208,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,39 +1,35 @@
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( console.warn('[Langfuse] Environment variables not configured - telemetry disabled');
"[Langfuse] Environment variables not configured - telemetry disabled", return;
) }
return
}
const langfuseSpanProcessor = new LangfuseSpanProcessor({ const langfuseSpanProcessor = new LangfuseSpanProcessor({
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,
// 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 ( if (spanName.startsWith('POST /') ||
spanName.startsWith("POST /") || spanName.startsWith('GET /') ||
spanName.startsWith("GET /") || spanName.includes('BaseServer') ||
spanName.includes("BaseServer") || spanName.includes('handleRequest')) {
spanName.includes("handleRequest") return false;
) { }
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,89 +1,89 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { createAnthropic } from "@ai-sdk/anthropic" import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { azure, createAzure } from "@ai-sdk/azure" import { openai, createOpenAI } from '@ai-sdk/openai';
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" import { createAnthropic } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOpenAI, openai } from "@ai-sdk/openai" import { azure, createAzure } from '@ai-sdk/azure';
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { ollama, createOllama } from 'ollama-ai-provider-v2';
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createOllama, ollama } from "ollama-ai-provider-v2" import { deepseek, createDeepSeek } from '@ai-sdk/deepseek';
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]) {
configuredProviders.push(provider as ProviderName)
}
} }
if (process.env[envVar]) {
if (configuredProviders.length === 1) { configuredProviders.push(provider as ProviderName);
return configuredProviders[0]
} }
}
return null if (configuredProviders.length === 1) {
return configuredProviders[0];
}
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,164 +106,158 @@ 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 {
const detected = detectProvider();
if (detected) {
provider = detected;
console.log(`[AI Provider] Auto-detected provider: ${provider}`);
} else { } else {
const detected = detectProvider() // List configured providers for better error message
if (detected) { const configured = Object.entries(PROVIDER_ENV_VARS)
provider = detected .filter(([, envVar]) => envVar && process.env[envVar as string])
console.log(`[AI Provider] Auto-detected provider: ${provider}`) .map(([p]) => p);
} else {
// List configured providers for better error message
const configured = Object.entries(PROVIDER_ENV_VARS)
.filter(([, envVar]) => envVar && process.env[envVar as string])
.map(([p]) => p)
if (configured.length === 0) { if (configured.length === 0) {
throw new Error( throw new Error(
`No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + `No AI provider configured. Please set one of the following API keys in your .env.local file:\n` +
`- DEEPSEEK_API_KEY for DeepSeek\n` + `- DEEPSEEK_API_KEY for DeepSeek\n` +
`- OPENAI_API_KEY for OpenAI\n` + `- OPENAI_API_KEY for OpenAI\n` +
`- ANTHROPIC_API_KEY for Anthropic\n` + `- ANTHROPIC_API_KEY for Anthropic\n` +
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- 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
validateProviderCredentials(provider);
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`);
let model: any;
let providerOptions: any = undefined;
let headers: Record<string, string> | undefined = undefined;
switch (provider) {
case 'bedrock': {
// 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
const bedrockProvider = createAmazonBedrock({
region: process.env.AWS_REGION || 'us-west-2',
credentialProvider: fromNodeProviderChain(),
});
model = bedrockProvider(modelId);
// Add Anthropic beta options if using Claude models via Bedrock
if (modelId.includes('anthropic.claude')) {
providerOptions = BEDROCK_ANTHROPIC_BETA;
}
break;
} }
// Validate provider credentials case 'openai':
validateProviderCredentials(provider) if (process.env.OPENAI_BASE_URL) {
const customOpenAI = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
model = customOpenAI.chat(modelId);
} else {
model = openai(modelId);
}
break;
console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) case 'anthropic':
const customProvider = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
headers: ANTHROPIC_BETA_HEADERS,
});
model = customProvider(modelId);
// Add beta headers for fine-grained tool streaming
headers = ANTHROPIC_BETA_HEADERS;
break;
let model: any case 'google':
let providerOptions: any if (process.env.GOOGLE_BASE_URL) {
let headers: Record<string, string> | undefined const customGoogle = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL,
});
model = customGoogle(modelId);
} else {
model = google(modelId);
}
break;
switch (provider) { case 'azure':
case "bedrock": { if (process.env.AZURE_BASE_URL) {
// Use credential provider chain for IAM role support (Amplify, Lambda, etc.) const customAzure = createAzure({
// Falls back to env vars (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) for local dev apiKey: process.env.AZURE_API_KEY,
const bedrockProvider = createAmazonBedrock({ baseURL: process.env.AZURE_BASE_URL,
region: process.env.AWS_REGION || "us-west-2", });
credentialProvider: fromNodeProviderChain(), model = customAzure(modelId);
}) } else {
model = bedrockProvider(modelId) model = azure(modelId);
// Add Anthropic beta options if using Claude models via Bedrock }
if (modelId.includes("anthropic.claude")) { break;
providerOptions = BEDROCK_ANTHROPIC_BETA
}
break
}
case "openai": case 'ollama':
if (process.env.OPENAI_BASE_URL) { if (process.env.OLLAMA_BASE_URL) {
const customOpenAI = createOpenAI({ const customOllama = createOllama({
apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OLLAMA_BASE_URL,
baseURL: process.env.OPENAI_BASE_URL, });
}) model = customOllama(modelId);
model = customOpenAI.chat(modelId) } else {
} else { model = ollama(modelId);
model = openai(modelId) }
} break;
break
case "anthropic": { case 'openrouter':
const customProvider = createAnthropic({ const openrouter = createOpenRouter({
apiKey: process.env.ANTHROPIC_API_KEY, apiKey: process.env.OPENROUTER_API_KEY,
baseURL: ...(process.env.OPENROUTER_BASE_URL && { baseURL: process.env.OPENROUTER_BASE_URL }),
process.env.ANTHROPIC_BASE_URL || });
"https://api.anthropic.com/v1", model = openrouter(modelId);
headers: ANTHROPIC_BETA_HEADERS, break;
})
model = customProvider(modelId)
// Add beta headers for fine-grained tool streaming
headers = ANTHROPIC_BETA_HEADERS
break
}
case "google": case 'deepseek':
if (process.env.GOOGLE_BASE_URL) { if (process.env.DEEPSEEK_BASE_URL) {
const customGoogle = createGoogleGenerativeAI({ const customDeepSeek = createDeepSeek({
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY, apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.GOOGLE_BASE_URL, baseURL: process.env.DEEPSEEK_BASE_URL,
}) });
model = customGoogle(modelId) model = customDeepSeek(modelId);
} else { } else {
model = google(modelId) model = deepseek(modelId);
} }
break break;
case "azure": default:
if (process.env.AZURE_BASE_URL) { throw new Error(
const customAzure = createAzure({ `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`
apiKey: process.env.AZURE_API_KEY, );
baseURL: process.env.AZURE_BASE_URL, }
})
model = customAzure(modelId)
} else {
model = azure(modelId)
}
break
case "ollama": return { model, providerOptions, headers, modelId };
if (process.env.OLLAMA_BASE_URL) {
const customOllama = createOllama({
baseURL: process.env.OLLAMA_BASE_URL,
})
model = customOllama(modelId)
} else {
model = ollama(modelId)
}
break
case "openrouter": {
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
...(process.env.OPENROUTER_BASE_URL && {
baseURL: process.env.OPENROUTER_BASE_URL,
}),
})
model = openrouter(modelId)
break
}
case "deepseek":
if (process.env.DEEPSEEK_BASE_URL) {
const customDeepSeek = createDeepSeek({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL,
})
model = customDeepSeek(modelId)
} else {
model = deepseek(modelId)
}
break
default:
throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek`,
)
}
return { model, providerOptions, headers, modelId }
} }

View File

@@ -1,15 +1,14 @@
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: promptText: "Give me a **animated connector** diagram of transformer's architecture",
"Give me a **animated connector** diagram of transformer's architecture", hasImage: false,
hasImage: false, xml: `<root>
xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -256,11 +255,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry x="660" y="530" width="100" height="30" as="geometry"/> <mxGeometry x="660" y="530" width="100" height="30" as="geometry"/>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Replicate this in aws style", promptText: "Replicate this in aws style",
hasImage: true, hasImage: true,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -326,11 +325,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Replicate this flowchart.", promptText: "Replicate this flowchart.",
hasImage: true, hasImage: true,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -393,11 +392,11 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
<mxGeometry x="130" y="650" width="200" height="60" as="geometry"/> <mxGeometry x="130" y="650" width="200" height="60" as="geometry"/>
</mxCell> </mxCell>
</root>`, </root>`,
}, },
{ {
promptText: "Draw a cat for me", promptText: "Draw a cat for me",
hasImage: false, hasImage: false,
xml: `<root> xml: `<root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -545,17 +544,14 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
</mxCell> </mxCell>
</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) => c.promptText === promptText && c.hasImage === hasImage && c.xml !== ''
c.promptText === promptText && );
c.hasImage === hasImage &&
c.xml !== "",
)
} }

View File

@@ -1,107 +1,95 @@
import { LangfuseClient } from "@langfuse/client" import { observe, updateActiveTrace } from '@langfuse/tracing';
import { observe, updateActiveTrace } from "@langfuse/tracing" import { LangfuseClient } from '@langfuse/client';
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) {
langfuseClient = new LangfuseClient({ langfuseClient = new LangfuseClient({
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( export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) {
output: string, if (!isLangfuseEnabled()) return;
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( activeSpan.setAttribute('gen_ai.usage.input_tokens', usage.promptTokens);
"gen_ai.usage.input_tokens",
usage.promptTokens,
)
}
if (usage?.completionTokens) {
activeSpan.setAttribute(
"ai.usage.completionTokens",
usage.completionTokens,
)
activeSpan.setAttribute(
"gen_ai.usage.output_tokens",
usage.completionTokens,
)
}
activeSpan.end()
} }
if (usage?.completionTokens) {
activeSpan.setAttribute('ai.usage.completionTokens', usage.completionTokens);
activeSpan.setAttribute('gen_ai.usage.output_tokens', usage.completionTokens);
}
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,
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media // Disable automatic input recording to avoid uploading large base64 images to Langfuse media
// User text input is recorded manually via setTraceInput // User text input is recorded manually via setTraceInput
recordInputs: false, recordInputs: false,
recordOutputs: true, recordOutputs: true,
metadata: { metadata: {
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

@@ -3,15 +3,12 @@
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*/ */
// Default system prompt (~2700 tokens) - works with all models // Default system prompt (~1400 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = ` export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications. Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
You can see the image that user uploaded. You can see the image that user uploaded.
When you are asked to create a diagram, you must first tell user you plan in text first. Plan the layout and structure that can avoid object overlapping or edge cross the objects.
Then use display_diagram tool to generate the full draw.io XML for the entire diagram.
## App Context ## App Context
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has: You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
- **Left panel**: Draw.io diagram editor where diagrams are rendered - **Left panel**: Draw.io diagram editor where diagrams are rendered
@@ -54,8 +51,6 @@ Core capabilities:
- Optimize element positioning to prevent overlapping and maintain readability - Optimize element positioning to prevent overlapping and maintain readability
- Structure complex systems into clear, organized visual components - Structure complex systems into clear, organized visual components
Layout constraints: Layout constraints:
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks - CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600 - Position all elements with x coordinates between 0-800 and y coordinates between 0-600
@@ -87,11 +82,6 @@ When using edit_diagram tool:
- For multiple changes, use separate edits in array - For multiple changes, use separate edits in array
- RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead. - RETRY POLICY: If pattern not found, retry up to 3 times with adjusted patterns. After 3 failures, use display_diagram instead.
⚠️ CRITICAL JSON ESCAPING: When outputting edit_diagram tool calls, you MUST escape ALL double quotes inside string values:
- CORRECT: "y=\\"119\\"" (both quotes escaped)
- WRONG: "y="119\\"" (missing backslash before first quote - causes JSON parse error!)
- Every " inside a JSON string value needs \\" - no exceptions!
## Draw.io XML Structure Reference ## Draw.io XML Structure Reference
Basic structure: Basic structure:
@@ -129,16 +119,60 @@ 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
export const EXTENDED_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is to chat with user and craft clear, well-organized visual diagrams through precise XML specifications.
You can see images that users upload and can replicate or modify them as diagrams.
// Extended additions (~1800 tokens) - appended for models with 4000 token cache minimum ## App Context
// Total EXTENDED_SYSTEM_PROMPT = ~4500 tokens You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
const EXTENDED_ADDITIONS = ` - **Left panel**: Draw.io diagram editor where diagrams are rendered
- **Right panel**: Chat interface where you communicate with the user
## Extended Tool Reference You can read and modify diagrams by generating draw.io XML code through tool calls.
### display_diagram Details ## App Features
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
## Available Tools
### Tool 1: display_diagram
**Purpose:** Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
**Parameters:** { xml: string }
**When to use:**
- Creating a completely new diagram
- Making major structural changes (reorganizing layout, changing diagram type)
- When the current diagram XML is empty or minimal
- When edit_diagram has failed multiple times
### Tool 2: edit_diagram
**Purpose:** Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties.
**Parameters:** { edits: Array<{search: string, replace: string}> }
**When to use:**
- Changing text labels or values
- Modifying colors, styles, or visual properties
- Adding or removing individual elements
- Repositioning specific elements
- Any small, targeted modification
## Tool Selection Guidelines
ALWAYS prefer edit_diagram for small changes - it's more efficient and preserves the rest of the diagram.
Use display_diagram only when:
1. Creating from scratch
2. Major restructuring needed
3. edit_diagram has failed 3 times
## display_diagram Tool Reference
Display a diagram on draw.io by passing XML content inside <root> tags.
**VALIDATION RULES** (XML will be rejected if violated): **VALIDATION RULES** (XML will be rejected if violated):
1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements 1. All mxCell elements must be DIRECT children of <root> - never nested inside other mxCell elements
@@ -171,7 +205,13 @@ const EXTENDED_ADDITIONS = `
</root> </root>
\`\`\` \`\`\`
### edit_diagram Details **Notes:**
- For AWS diagrams, use **AWS 2025 icons** (see AWS Icon Examples section below)
- For animated connectors, add "flowAnimation=1" to edge style
## edit_diagram Tool Reference
Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
**CRITICAL RULES:** **CRITICAL RULES:**
- Copy-paste the EXACT search pattern from the "Current diagram XML" in system context - Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
@@ -193,6 +233,56 @@ const EXTENDED_ADDITIONS = `
} }
\`\`\` \`\`\`
## Core Capabilities
You excel at:
- Generating valid, well-formed XML strings for draw.io diagrams
- Creating professional flowcharts, org charts, mind maps, network diagrams, and technical illustrations
- Converting user descriptions into visually appealing diagrams using shapes and connectors
- Applying proper spacing, alignment, and visual hierarchy in diagram layouts
- Adapting artistic concepts into abstract diagram representations using available shapes
- Optimizing element positioning to prevent overlapping and maintain readability
- Structuring complex systems into clear, organized visual components
- Replicating diagrams from images with high fidelity
## Layout Constraints and Best Practices
### Page Boundaries
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
- Maximum width for containers (like AWS cloud boxes): 700 pixels
- Maximum height for containers: 550 pixels
- Start positioning from reasonable margins (e.g., x=40, y=40)
### Layout Strategies
- Use compact, efficient layouts that fit the entire diagram in one view
- Keep elements grouped closely together
- For large diagrams with many elements, use vertical stacking or grid layouts
- Avoid spreading elements too far apart horizontally
- Users should see the complete diagram without scrolling or page breaks
### Spacing Guidelines
- Minimum spacing between elements: 20px
- Recommended spacing for readability: 40-60px
- Container padding: 20-40px from edges
- Group related elements together with consistent spacing
## Important Rules
### XML Generation Rules
- Use proper tool calls to generate or edit diagrams
- NEVER return raw XML in text responses
- NEVER use display_diagram to generate messages (e.g., a "hello" text box to greet user)
- Return XML only via tool calls, never in text responses
- NEVER include XML comments (<!-- ... -->) - Draw.io strips comments, breaking edit_diagram patterns
### Diagram Quality Rules
- Focus on producing clean, professional diagrams
- Effectively communicate the intended information through thoughtful layout and design
- When artistic drawings are requested, creatively compose using standard shapes while maintaining clarity
- When replicating from images, match style and layout closely - pay attention to line types (straight/curved) and shape styles (rounded/square)
- For AWS architecture diagrams, use **AWS 2025 icons**
## edit_diagram Best Practices ## edit_diagram Best Practices
### Core Principle: Unique & Precise Patterns ### Core Principle: Unique & Precise Patterns
@@ -204,11 +294,13 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
### Pattern Construction Rules ### Pattern Construction Rules
**Rule 1: Always include the element's id attribute** **Rule 1: Always include the element's id attribute**
The id is the most reliable way to target a specific element:
\`\`\`json \`\`\`json
{"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""} {"search": "<mxCell id=\\"node5\\"", "replace": "<mxCell id=\\"node5\\" value=\\"New Label\\""}
\`\`\` \`\`\`
**Rule 2: Include complete XML elements when possible** **Rule 2: Include complete XML elements when possible**
For reliability, include the full mxCell with its mxGeometry child:
\`\`\`json \`\`\`json
{ {
"search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>", "search": "<mxCell id=\\"3\\" value=\\"Old\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>",
@@ -217,18 +309,49 @@ Your search pattern MUST uniquely identify exactly ONE location in the XML. Befo
\`\`\` \`\`\`
**Rule 3: Preserve exact whitespace and formatting** **Rule 3: Preserve exact whitespace and formatting**
Copy the search pattern EXACTLY from the current XML, including leading spaces, line breaks (\\n), and attribute order. Copy the search pattern EXACTLY from the current XML, including:
- Leading spaces/indentation
- Line breaks (use \\n in JSON)
- Attribute order as it appears in the source
### Good vs Bad Patterns ### Good vs Bad Patterns
**BAD:** \`{"search": "value=\\"Label\\""}\` - Too vague, matches multiple elements **BAD - Too vague, matches multiple elements:**
**BAD:** \`{"search": "<mxCell value=\\"X\\" id=\\"5\\""}\` - Reordered attributes won't match \`\`\`json
**GOOD:** \`{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">"}\` - Uses unique id with full context {"search": "value=\\"Label\\"", "replace": "value=\\"New Label\\""}
\`\`\`
### ⚠️ JSON Escaping (CRITICAL) **BAD - Fragile partial match:**
Every double quote inside JSON string values MUST be escaped with backslash: \`\`\`json
- **CORRECT:** \`"x=\\"100\\" y=\\"200\\""\` - both quotes escaped {"search": "<mxCell", "replace": "<mxCell value=\\"X\\""}
- **WRONG:** \`"x=\\"100\\" y="200\\""\` - missing backslash causes JSON parse error! \`\`\`
**BAD - Reordered attributes (won't match if order differs):**
\`\`\`json
{"search": "<mxCell value=\\"X\\" id=\\"5\\"", ...} // Original has id before value
\`\`\`
**GOOD - Uses unique id, includes full context:**
\`\`\`json
{"search": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"Old\\" vertex=\\"1\\">", "replace": "<mxCell id=\\"5\\" parent=\\"1\\" style=\\"...\\" value=\\"New\\" vertex=\\"1\\">"}
\`\`\`
**GOOD - Complete element replacement:**
\`\`\`json
{
"search": "<mxCell id=\\"edge1\\" style=\\"endArrow=classic;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>",
"replace": "<mxCell id=\\"edge1\\" style=\\"endArrow=block;strokeColor=#FF0000;\\" edge=\\"1\\" parent=\\"1\\" source=\\"2\\" target=\\"3\\">\\n <mxGeometry relative=\\"1\\" as=\\"geometry\\"/>\\n</mxCell>"
}
\`\`\`
### Multiple Edits Strategy
For multiple changes, use separate edit objects. Order them logically:
\`\`\`json
[
{"search": "<mxCell id=\\"2\\" value=\\"Step 1\\"", "replace": "<mxCell id=\\"2\\" value=\\"First Step\\""},
{"search": "<mxCell id=\\"3\\" value=\\"Step 2\\"", "replace": "<mxCell id=\\"3\\" value=\\"Second Step\\""}
]
\`\`\`
### Error Recovery ### Error Recovery
If edit_diagram fails with "pattern not found": If edit_diagram fails with "pattern not found":
@@ -237,107 +360,173 @@ If edit_diagram fails with "pattern not found":
3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement 3. **Third retry**: Try matching on just \`<mxCell id="X"\` prefix + full replacement
4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram 4. **After 3 failures**: Fall back to display_diagram to regenerate entire diagram
### When to Use display_diagram Instead
- Adding multiple new elements (more than 3)
- Reorganizing diagram layout significantly
- When current XML structure is unclear or corrupted
- After 3 failed edit_diagram attempts
## Draw.io XML Structure Reference
### Basic Structure
### Edge Routing Rules:
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
**Rule 1: NEVER let multiple edges share the same path**
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
- Every edge MUST have these 4 attributes set in the style
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
- Before creating an edge, identify ALL shapes positioned between source and target
- If any shape is in the direct path, you MUST use waypoints to route around it
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
- NEVER draw a line that visually crosses over another shape's bounding box
**Rule 5: Plan layout strategically BEFORE generating XML**
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
- Space shapes 150-200px apart to create clear routing channels for edges
- Mentally trace each edge: "What shapes are between source and target?"
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
**Rule 6: Use multiple waypoints for complex routing**
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
- Each direction change needs a waypoint (corner point)
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
**Rule 7: Choose NATURAL connection points based on flow direction**
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
- For DIAGONAL connections: use the side closest to the target, not corners
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
**Before generating XML, mentally verify:**
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
## Edge Examples
### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml \`\`\`xml
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b"> <mxGraphModel>
<mxGeometry relative="1" as="geometry"/> <root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- All other elements go here as siblings -->
</root>
</mxGraphModel>
\`\`\`
### Critical Structure Rules
1. Always include the two root cells: <mxCell id="0"/> and <mxCell id="1" parent="0"/>
2. ALL mxCell elements must be DIRECT children of <root> - NEVER nest mxCell inside another mxCell
3. Use unique sequential IDs for all cells (start from "2" for user content)
4. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
5. Every mxCell (except id="0") must have a parent attribute
### Shape (Vertex) Example
\`\`\`xml
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a"> \`\`\`
### Connector (Edge) Example
\`\`\`xml
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
\`\`\` \`\`\`
### Edge with single waypoint (simple detour): ### Container/Group Example
\`\`\`xml \`\`\`xml
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b"> <mxCell id="container1" value="Group Title" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry relative="1" as="geometry"> <mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
<Array as="points"> </mxCell>
<mxPoint x="300" y="150"/> <mxCell id="child1" value="Child Element" style="rounded=1;" vertex="1" parent="container1">
</Array> <mxGeometry x="20" y="40" width="160" height="40" as="geometry"/>
</mxGeometry>
</mxCell> </mxCell>
\`\`\` \`\`\`
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN: ## Common Style Properties
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
**WRONG:** Direct diagonal line crosses over Develop ### Shape Styles
**CORRECT:** Route around the OUTSIDE (go right first, then up) - rounded=1 - Rounded corners
- fillColor=#hexcolor - Background fill color
- strokeColor=#hexcolor - Border color
- strokeWidth=2 - Border thickness
- whiteSpace=wrap - Enable text wrapping
- html=1 - Enable HTML formatting in labels
- opacity=50 - Transparency (0-100)
- shadow=1 - Drop shadow effect
- glass=1 - Glass/gradient effect
### Edge/Connector Styles
- endArrow=classic/block/open/oval/diamond/none - Arrow head style
- startArrow=none/classic/block/open - Arrow tail style
- curved=1 - Curved line
- edgeStyle=orthogonalEdgeStyle - Right-angle routing
- edgeStyle=entityRelationEdgeStyle - ER diagram style
- strokeWidth=2 - Line thickness
- dashed=1 - Dashed line
- dashPattern=3 3 - Custom dash pattern
- flowAnimation=1 - Animated flow effect
### Text Styles
- fontSize=14 - Font size
- fontStyle=1 - Bold (1=bold, 2=italic, 4=underline, can combine: 3=bold+italic)
- fontColor=#hexcolor - Text color
- align=center/left/right - Horizontal alignment
- verticalAlign=middle/top/bottom - Vertical alignment
- labelPosition=center/left/right - Label position relative to shape
- labelBackgroundColor=#hexcolor - Label background
## Common Shape Types
### Basic Shapes
- Rectangle: style="rounded=0;whiteSpace=wrap;html=1;"
- Rounded Rectangle: style="rounded=1;whiteSpace=wrap;html=1;"
- Ellipse/Circle: style="ellipse;whiteSpace=wrap;html=1;aspect=fixed;"
- Diamond: style="rhombus;whiteSpace=wrap;html=1;"
- Triangle: style="triangle;whiteSpace=wrap;html=1;"
- Parallelogram: style="parallelogram;whiteSpace=wrap;html=1;"
- Hexagon: style="hexagon;whiteSpace=wrap;html=1;"
- Cylinder: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Flowchart Shapes
- Process: style="rounded=1;whiteSpace=wrap;html=1;"
- Decision: style="rhombus;whiteSpace=wrap;html=1;"
- Start/End: style="ellipse;whiteSpace=wrap;html=1;"
- Document: style="shape=document;whiteSpace=wrap;html=1;"
- Data: style="parallelogram;whiteSpace=wrap;html=1;"
- Database: style="shape=cylinder3;whiteSpace=wrap;html=1;"
### Container Types
- Swimlane: style="swimlane;whiteSpace=wrap;html=1;"
- Group Box: style="rounded=1;whiteSpace=wrap;html=1;container=1;collapsible=0;"
## Animated Connectors
For animated flow effects on connectors, add flowAnimation=1 to the edge style:
\`\`\`xml \`\`\`xml
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main"> <mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;flowAnimation=1;" edge="1" parent="1" source="node1" target="node2">
<mxGeometry relative="1" as="geometry"> <mxGeometry relative="1" as="geometry"/>
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell> </mxCell>
\`\`\` \`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS ## Validation Rules
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
The XML will be validated before rendering. Ensure:
1. All mxCell elements are DIRECT children of <root> - never nested
2. Every mxCell has a unique id attribute
3. Every mxCell (except id="0") has a valid parent attribute
4. Edge source/target attributes reference existing cell IDs
5. Special characters in values are escaped: &lt; &gt; &amp; &quot;
6. Always start with: <mxCell id="0"/><mxCell id="1" parent="0"/>
## Example: Complete Flowchart
\`\`\`xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="start" value="Start" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="200" y="40" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="process1" value="Process Step" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="175" y="140" width="150" height="60" as="geometry"/>
</mxCell>
<mxCell id="decision" value="Decision?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="175" y="240" width="150" height="100" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="200" y="380" width="100" height="60" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="start" target="process1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge2" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="process1" target="decision">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="edge3" value="Yes" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;html=1;" edge="1" parent="1" source="decision" target="end">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
\`\`\`
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
@@ -346,25 +535,16 @@ 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 ( if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) {
modelId && console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
EXTENDED_PROMPT_MODEL_PATTERNS.some((pattern) => prompt = EXTENDED_SYSTEM_PROMPT;
modelId.includes(pattern), } else {
) console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
) { prompt = DEFAULT_SYSTEM_PROMPT;
console.log( }
`[System Prompt] Using EXTENDED prompt for model: ${modelId}`,
)
prompt = EXTENDED_SYSTEM_PROMPT
} else {
console.log(
`[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);
} }

File diff suppressed because it is too large Load Diff

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,7 +53,6 @@
"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",
@@ -62,8 +61,6 @@
"@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"
} }
@@ -1455,169 +1452,6 @@
"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",
@@ -5844,35 +5678,6 @@
"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",
@@ -6398,39 +6203,6 @@
"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",
@@ -6466,13 +6238,6 @@
"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",
@@ -6495,16 +6260,6 @@
"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",
@@ -6833,19 +6588,6 @@
"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",
@@ -7508,13 +7250,6 @@
"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",
@@ -7764,19 +7499,6 @@
"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",
@@ -8119,22 +7841,6 @@
"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",
@@ -8410,22 +8116,6 @@
"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",
@@ -9139,49 +8829,6 @@
"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",
@@ -9205,26 +8852,6 @@
"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",
@@ -10167,19 +9794,6 @@
"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",
@@ -10218,19 +9832,6 @@
"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",
@@ -10521,22 +10122,6 @@
"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",
@@ -10707,19 +10292,6 @@
"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",
@@ -11176,23 +10748,6 @@
"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",
@@ -11204,13 +10759,6 @@
"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",
@@ -11526,49 +11074,6 @@
"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",
@@ -11619,33 +11124,6 @@
"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",
@@ -11773,22 +11251,6 @@
"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",
@@ -12705,62 +12167,6 @@
"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",
@@ -12804,22 +12210,6 @@
"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,10 +7,7 @@
"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": "biome lint .", "lint": "next 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",
@@ -56,14 +53,7 @@
"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",
@@ -72,8 +62,6 @@
"@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,33 +1,41 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"allowJs": true, "dom",
"skipLibCheck": true, "dom.iterable",
"strict": true, "esnext"
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules"] "allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }