mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 23:02:31 +08:00
Compare commits
1 Commits
fix/restor
...
fix/preven
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d0d4039d |
13
README.md
13
README.md
@@ -81,7 +81,7 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
## Multi-Provider Support
|
## Multi-Provider Support
|
||||||
|
|
||||||
- AWS Bedrock (default)
|
- AWS Bedrock (default)
|
||||||
- OpenAI
|
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
|
||||||
|
|
||||||
📖 **[Detailed Provider Configuration Guide](./docs/ai-providers.md)** - See setup instructions for each provider.
|
|
||||||
|
|
||||||
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-4o, Gemini 2.0, and DeepSeek V3/R1.
|
|
||||||
|
|
||||||
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
Note that `claude-sonnet-4-5` has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -149,11 +143,8 @@ Edit `.env.local` and configure your chosen provider:
|
|||||||
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
- Set `AI_PROVIDER` to your chosen provider (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- Set `AI_MODEL` to the specific model you want to use
|
- Set `AI_MODEL` to the specific model you want to use
|
||||||
- Add the required API keys for your provider
|
- Add the required API keys for your provider
|
||||||
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
|
|
||||||
|
|
||||||
> Warning: If you do not set `ACCESS_CODE_LIST`, anyone can access your deployed site directly, which may lead to rapid depletion of your token. It is recommended to set this option.
|
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
|
||||||
|
|
||||||
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
|
||||||
|
|
||||||
4. Run the development server:
|
4. Run the development server:
|
||||||
|
|
||||||
|
|||||||
13
README_CN.md
13
README_CN.md
@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
## 多提供商支持
|
## 多提供商支持
|
||||||
|
|
||||||
- AWS Bedrock(默认)
|
- AWS Bedrock(默认)
|
||||||
- OpenAI
|
- OpenAI / OpenAI兼容API(通过 `OPENAI_BASE_URL`)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
|
|
||||||
|
|
||||||
📖 **[详细的提供商配置指南](./docs/ai-providers.md)** - 查看各提供商的设置说明。
|
|
||||||
|
|
||||||
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用Claude Sonnet 4.5、GPT-4o、Gemini 2.0和DeepSeek V3/R1。
|
|
||||||
|
|
||||||
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
注意:`claude-sonnet-4-5` 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
|||||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||||
- 添加您的提供商所需的API密钥
|
- 添加您的提供商所需的API密钥
|
||||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
|
||||||
|
|
||||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例。
|
||||||
|
|
||||||
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
|
|
||||||
|
|
||||||
4. 运行开发服务器:
|
4. 运行开发服务器:
|
||||||
|
|
||||||
|
|||||||
13
README_JA.md
13
README_JA.md
@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
## マルチプロバイダーサポート
|
## マルチプロバイダーサポート
|
||||||
|
|
||||||
- AWS Bedrock(デフォルト)
|
- AWS Bedrock(デフォルト)
|
||||||
- OpenAI
|
- OpenAI / OpenAI互換API(`OPENAI_BASE_URL`経由)
|
||||||
- Anthropic
|
- Anthropic
|
||||||
- Google AI
|
- Google AI
|
||||||
- Azure OpenAI
|
- Azure OpenAI
|
||||||
@@ -89,12 +89,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
|||||||
- OpenRouter
|
- OpenRouter
|
||||||
- DeepSeek
|
- DeepSeek
|
||||||
|
|
||||||
AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタムエンドポイントをサポートしています。
|
|
||||||
|
|
||||||
📖 **[詳細なプロバイダー設定ガイド](./docs/ai-providers.md)** - 各プロバイダーの設定手順をご覧ください。
|
|
||||||
|
|
||||||
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-4o、Gemini 2.0、DeepSeek V3/R1を推奨します。
|
|
||||||
|
|
||||||
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude-sonnet-4-5`はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
@@ -149,11 +143,8 @@ cp env.example .env.local
|
|||||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||||
- `AI_MODEL`を使用する特定のモデルに設定
|
- `AI_MODEL`を使用する特定のモデルに設定
|
||||||
- プロバイダーに必要なAPIキーを追加
|
- プロバイダーに必要なAPIキーを追加
|
||||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
|
||||||
|
|
||||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください。
|
||||||
|
|
||||||
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
|
|
||||||
|
|
||||||
4. 開発サーバーを起動:
|
4. 開発サーバーを起動:
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||||
import { getAIModel } from '@/lib/ai-providers';
|
import { getAIModel } from '@/lib/ai-providers';
|
||||||
import { findCachedResponse } from '@/lib/cached-responses';
|
import { findCachedResponse } from '@/lib/cached-responses';
|
||||||
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse';
|
|
||||||
import { getSystemPrompt } from '@/lib/system-prompts';
|
import { getSystemPrompt } from '@/lib/system-prompts';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const maxDuration = 300;
|
export const maxDuration = 300;
|
||||||
|
|
||||||
// File upload limits (must match client-side)
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
||||||
const MAX_FILES = 5;
|
|
||||||
|
|
||||||
// Helper function to validate file parts in messages
|
|
||||||
function validateFileParts(messages: any[]): { valid: boolean; error?: string } {
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || [];
|
|
||||||
|
|
||||||
if (fileParts.length > MAX_FILES) {
|
|
||||||
return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePart of fileParts) {
|
|
||||||
// Data URLs format: data:image/png;base64,<data>
|
|
||||||
// Base64 increases size by ~33%, so we check the decoded size
|
|
||||||
if (filePart.url && 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, '');
|
||||||
@@ -62,46 +31,7 @@ function createCachedStreamResponse(xml: string): Response {
|
|||||||
|
|
||||||
// Inner handler function
|
// Inner handler function
|
||||||
async function handleChatRequest(req: Request): Promise<Response> {
|
async function handleChatRequest(req: Request): Promise<Response> {
|
||||||
// Check for access code
|
const { messages, xml } = await req.json();
|
||||||
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
|
|
||||||
if (accessCodes.length > 0) {
|
|
||||||
const accessCodeHeader = req.headers.get('x-access-code');
|
|
||||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: 'Invalid or missing access code. Please configure it in Settings.' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messages, xml, sessionId } = await req.json();
|
|
||||||
|
|
||||||
// Get user IP for Langfuse tracking
|
|
||||||
const forwardedFor = req.headers.get('x-forwarded-for');
|
|
||||||
const userId = forwardedFor?.split(',')[0]?.trim() || 'anonymous';
|
|
||||||
|
|
||||||
// Validate sessionId for Langfuse (must be string, max 200 chars)
|
|
||||||
const validSessionId = sessionId && typeof sessionId === 'string' && sessionId.length <= 200
|
|
||||||
? sessionId
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Extract user input text for Langfuse trace
|
|
||||||
const currentMessage = messages[messages.length - 1];
|
|
||||||
const userInputText = currentMessage?.parts?.find((p: any) => p.type === 'text')?.text || '';
|
|
||||||
|
|
||||||
// Update Langfuse trace with input, session, and user
|
|
||||||
setTraceInput({
|
|
||||||
input: userInputText,
|
|
||||||
sessionId: validSessionId,
|
|
||||||
userId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// === FILE VALIDATION START ===
|
|
||||||
const fileValidation = validateFileParts(messages);
|
|
||||||
if (!fileValidation.valid) {
|
|
||||||
return Response.json({ error: fileValidation.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
// === FILE VALIDATION END ===
|
|
||||||
|
|
||||||
// === CACHE CHECK START ===
|
// === CACHE CHECK START ===
|
||||||
const isFirstMessage = messages.length === 1;
|
const isFirstMessage = messages.length === 1;
|
||||||
@@ -224,19 +154,28 @@ ${lastMessageText}
|
|||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
...(providerOptions && { providerOptions }),
|
...(providerOptions && { providerOptions }),
|
||||||
...(headers && { headers }),
|
...(headers && { headers }),
|
||||||
// Langfuse telemetry config (returns undefined if not configured)
|
onFinish: ({ usage, providerMetadata, finishReason, text, toolCalls }) => {
|
||||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
// Detect potential mid-stream failures (e.g., Bedrock 503 ServiceUnavailableException)
|
||||||
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }),
|
// When this happens, usage is empty and providerMetadata is undefined
|
||||||
}),
|
const hasUsage = usage && Object.keys(usage).length > 0;
|
||||||
onFinish: ({ text, usage, providerMetadata }) => {
|
if (!hasUsage) {
|
||||||
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
|
console.error('[Stream Error] Empty usage detected - possible Bedrock 503 or mid-stream failure');
|
||||||
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
|
console.error('[Stream Error] finishReason:', finishReason);
|
||||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
console.error('[Stream Error] text received:', text?.substring(0, 200) || '(none)');
|
||||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
console.error('[Stream Error] toolCalls:', toolCalls?.length || 0);
|
||||||
setTraceOutput(text, {
|
// Log the user's last message for debugging
|
||||||
promptTokens: usage?.inputTokens,
|
const lastUserMsg = enhancedMessages.filter(m => m.role === 'user').pop();
|
||||||
completionTokens: usage?.outputTokens,
|
if (lastUserMsg) {
|
||||||
});
|
const content = lastUserMsg.content;
|
||||||
|
const preview = Array.isArray(content)
|
||||||
|
? (content.find((c) => c.type === 'text') as { type: 'text'; text: string } | undefined)?.text?.substring(0, 100)
|
||||||
|
: String(content).substring(0, 100);
|
||||||
|
console.error('[Stream Error] Last user message preview:', preview);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
|
||||||
|
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
tools: {
|
tools: {
|
||||||
// Client-side tool that will be executed on the client
|
// Client-side tool that will be executed on the client
|
||||||
@@ -300,11 +239,51 @@ IMPORTANT: Keep edits concise:
|
|||||||
temperature: 0,
|
temperature: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.toUIMessageStreamResponse();
|
// Error handler function to provide detailed error messages
|
||||||
|
function errorHandler(error: unknown) {
|
||||||
|
if (error == null) {
|
||||||
|
return 'unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorString = typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: JSON.stringify(error);
|
||||||
|
|
||||||
|
// Check for Bedrock service errors (503, throttling, etc.)
|
||||||
|
if (errorString.includes('ServiceUnavailable') ||
|
||||||
|
errorString.includes('503') ||
|
||||||
|
errorString.includes('temporarily unavailable')) {
|
||||||
|
console.error('[Bedrock Error] ServiceUnavailableException:', errorString);
|
||||||
|
return 'The AI service is temporarily unavailable. Please try again in a few seconds.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for throttling errors
|
||||||
|
if (errorString.includes('ThrottlingException') ||
|
||||||
|
errorString.includes('rate limit') ||
|
||||||
|
errorString.includes('too many requests') ||
|
||||||
|
errorString.includes('429')) {
|
||||||
|
console.error('[Bedrock Error] ThrottlingException:', errorString);
|
||||||
|
return 'Too many requests. Please wait a moment and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for image not supported error (e.g., DeepSeek models)
|
||||||
|
if (errorString.includes('image_url') ||
|
||||||
|
errorString.includes('unknown variant') ||
|
||||||
|
(errorString.includes('image') && errorString.includes('not supported'))) {
|
||||||
|
return 'This model does not support image inputs. Please remove the image and try again, or switch to a vision-capable model.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toUIMessageStreamResponse({
|
||||||
|
onError: errorHandler,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap handler with error handling
|
export async function POST(req: Request) {
|
||||||
async function safeHandler(req: Request): Promise<Response> {
|
|
||||||
try {
|
try {
|
||||||
return await handleChatRequest(req);
|
return await handleChatRequest(req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,10 +291,3 @@ async function safeHandler(req: Request): Promise<Response> {
|
|||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap with Langfuse observe (if configured)
|
|
||||||
const observedHandler = wrapWithObserve(safeHandler);
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
return observedHandler(req);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const accessCodes = process.env.ACCESS_CODE_LIST?.split(',').map(code => code.trim()).filter(Boolean) || [];
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
accessCodeRequired: accessCodes.length > 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { getLangfuseClient } from '@/lib/langfuse';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const feedbackSchema = z.object({
|
|
||||||
messageId: z.string().min(1).max(200),
|
|
||||||
feedback: z.enum(['good', 'bad']),
|
|
||||||
sessionId: z.string().min(1).max(200).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const langfuse = getLangfuseClient();
|
|
||||||
if (!langfuse) {
|
|
||||||
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`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { getLangfuseClient } from '@/lib/langfuse';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const saveSchema = z.object({
|
|
||||||
filename: z.string().min(1).max(255),
|
|
||||||
format: z.enum(['drawio', 'png', 'svg']),
|
|
||||||
sessionId: z.string().min(1).max(200).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const langfuse = getLangfuseClient();
|
|
||||||
if (!langfuse) {
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -153,12 +152,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
|
||||||
[data-slot="scroll-area-viewport"] > div {
|
|
||||||
display: block !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export default function RootLayout({
|
|||||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<DiagramProvider>{children}</DiagramProvider>
|
<DiagramProvider>{children}</DiagramProvider>
|
||||||
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</body>
|
</body>
|
||||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||||
|
|||||||
125
app/page.tsx
125
app/page.tsx
@@ -1,28 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { DrawIoEmbed } from "react-drawio";
|
import { DrawIoEmbed } from "react-drawio";
|
||||||
import ChatPanel from "@/components/chat-panel";
|
import ChatPanel from "@/components/chat-panel";
|
||||||
import { useDiagram } from "@/contexts/diagram-context";
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { Monitor } from "lucide-react";
|
import { Monitor } from "lucide-react";
|
||||||
import {
|
|
||||||
ResizablePanelGroup,
|
|
||||||
ResizablePanel,
|
|
||||||
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">(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const saved = localStorage.getItem("drawio-theme");
|
|
||||||
if (saved === "min" || saved === "sketch") return saved;
|
|
||||||
}
|
|
||||||
return "min";
|
|
||||||
});
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
@@ -34,29 +20,16 @@ export default function Home() {
|
|||||||
return () => window.removeEventListener("resize", checkMobile);
|
return () => window.removeEventListener("resize", checkMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleChatPanel = () => {
|
|
||||||
const panel = chatPanelRef.current;
|
|
||||||
if (panel) {
|
|
||||||
if (panel.isCollapsed()) {
|
|
||||||
panel.expand();
|
|
||||||
setIsChatVisible(true);
|
|
||||||
} else {
|
|
||||||
panel.collapse();
|
|
||||||
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();
|
setIsChatVisible((prev) => !prev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
@@ -64,16 +37,15 @@ export default function Home() {
|
|||||||
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="flex h-screen bg-background relative overflow-hidden">
|
||||||
{/* Mobile warning overlay */}
|
{/* Mobile warning overlay */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||||
@@ -85,62 +57,41 @@ export default function Home() {
|
|||||||
Desktop Required
|
Desktop Required
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
This application works best on desktop or laptop
|
This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
|
||||||
devices. Please open it on a larger screen for the
|
|
||||||
full experience.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
{/* Draw.io Canvas */}
|
||||||
{/* Draw.io Canvas */}
|
<div
|
||||||
<ResizablePanel defaultSize={67} minSize={30}>
|
className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
|
||||||
<div className="h-full relative p-2">
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
<div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||||
<DrawIoEmbed
|
<DrawIoEmbed
|
||||||
key={drawioUi}
|
ref={drawioRef}
|
||||||
ref={drawioRef}
|
onExport={handleDiagramExport}
|
||||||
onExport={handleDiagramExport}
|
urlParameters={{
|
||||||
urlParameters={{
|
spin: true,
|
||||||
ui: drawioUi,
|
libraries: false,
|
||||||
spin: true,
|
saveAndExit: false,
|
||||||
libraries: false,
|
noExitBtn: true,
|
||||||
saveAndExit: false,
|
}}
|
||||||
noExitBtn: true,
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
{/* Chat Panel */}
|
||||||
|
<div
|
||||||
{/* Chat Panel */}
|
className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
|
||||||
<ResizablePanel
|
>
|
||||||
ref={chatPanelRef}
|
<div className="h-full py-2 pr-2">
|
||||||
defaultSize={33}
|
<ChatPanel
|
||||||
minSize={15}
|
isVisible={isChatVisible}
|
||||||
maxSize={50}
|
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
|
||||||
collapsible
|
/>
|
||||||
collapsedSize={3}
|
</div>
|
||||||
onCollapse={() => setIsChatVisible(false)}
|
</div>
|
||||||
onExpand={() => setIsChatVisible(true)}
|
|
||||||
>
|
|
||||||
<div className="h-full py-2 pr-2">
|
|
||||||
<ChatPanel
|
|
||||||
isVisible={isChatVisible}
|
|
||||||
onToggleVisibility={toggleChatPanel}
|
|
||||||
drawioUi={drawioUi}
|
|
||||||
onToggleDrawioUi={() => {
|
|
||||||
const newTheme = drawioUi === "min" ? "sketch" : "min";
|
|
||||||
localStorage.setItem("drawio-theme", newTheme);
|
|
||||||
setDrawioUi(newTheme);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ResizablePanel>
|
|
||||||
</ResizablePanelGroup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||||
import { SaveDialog } from "@/components/save-dialog";
|
import { SaveDialog } from "@/components/save-dialog";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
@@ -20,80 +12,12 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
History,
|
History,
|
||||||
Download,
|
Download,
|
||||||
PenTool,
|
Paperclip,
|
||||||
LayoutGrid,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { FilePreviewList } from "./file-preview-list";
|
import { FilePreviewList } from "./file-preview-list";
|
||||||
import { useDiagram } from "@/contexts/diagram-context";
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { HistoryDialog } from "@/components/history-dialog";
|
import { HistoryDialog } from "@/components/history-dialog";
|
||||||
import { ErrorToast } from "@/components/error-toast";
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
|
||||||
const MAX_FILES = 5;
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
const mb = bytes / 1024 / 1024;
|
|
||||||
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
||||||
return `${mb.toFixed(2)}MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorToast(message: React.ReactNode) {
|
|
||||||
toast.custom(
|
|
||||||
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
|
|
||||||
{ duration: 5000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidationResult {
|
|
||||||
validFiles: File[];
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
|
|
||||||
const errors: string[] = [];
|
|
||||||
const validFiles: File[] = [];
|
|
||||||
|
|
||||||
const availableSlots = MAX_FILES - existingCount;
|
|
||||||
|
|
||||||
if (availableSlots <= 0) {
|
|
||||||
errors.push(`Maximum ${MAX_FILES} files allowed`);
|
|
||||||
return { validFiles, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of newFiles) {
|
|
||||||
if (validFiles.length >= availableSlots) {
|
|
||||||
errors.push(`Only ${availableSlots} more file(s) allowed`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
|
|
||||||
} else {
|
|
||||||
validFiles.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { validFiles, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
function showValidationErrors(errors: string[]) {
|
|
||||||
if (errors.length === 0) return;
|
|
||||||
|
|
||||||
if (errors.length === 1) {
|
|
||||||
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
|
|
||||||
} else {
|
|
||||||
showErrorToast(
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{errors.length} files rejected:</span>
|
|
||||||
<ul className="text-muted-foreground text-xs list-disc list-inside">
|
|
||||||
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
|
|
||||||
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
input: string;
|
input: string;
|
||||||
@@ -105,10 +29,7 @@ interface ChatInputProps {
|
|||||||
onFileChange?: (files: File[]) => void;
|
onFileChange?: (files: File[]) => void;
|
||||||
showHistory?: boolean;
|
showHistory?: boolean;
|
||||||
onToggleHistory?: (show: boolean) => void;
|
onToggleHistory?: (show: boolean) => void;
|
||||||
sessionId?: string;
|
|
||||||
error?: Error | null;
|
error?: Error | null;
|
||||||
drawioUi?: "min" | "sketch";
|
|
||||||
onToggleDrawioUi?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -121,10 +42,7 @@ export function ChatInput({
|
|||||||
onFileChange = () => {},
|
onFileChange = () => {},
|
||||||
showHistory = false,
|
showHistory = false,
|
||||||
onToggleHistory = () => {},
|
onToggleHistory = () => {},
|
||||||
sessionId,
|
|
||||||
error = null,
|
error = null,
|
||||||
drawioUi = "min",
|
|
||||||
onToggleDrawioUi = () => {},
|
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -132,11 +50,13 @@ export function ChatInput({
|
|||||||
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);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
||||||
|
}, [status, isDisabled]);
|
||||||
|
|
||||||
const adjustTextareaHeight = useCallback(() => {
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
@@ -169,20 +89,23 @@ export function ChatInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (imageItems.length > 0) {
|
||||||
const imageFiles = (await Promise.all(
|
const imageFiles = await Promise.all(
|
||||||
imageItems.map(async (item, index) => {
|
imageItems.map(async (item) => {
|
||||||
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()}.${file.type.split("/")[1]}`,
|
||||||
{ type: file.type }
|
{
|
||||||
|
type: file.type,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)).filter((f): f is File => f !== null);
|
);
|
||||||
|
|
||||||
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
const validFiles = imageFiles.filter(
|
||||||
showValidationErrors(errors);
|
(file): file is File => file !== null
|
||||||
|
);
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
onFileChange([...files, ...validFiles]);
|
onFileChange([...files, ...validFiles]);
|
||||||
}
|
}
|
||||||
@@ -191,15 +114,7 @@ export function ChatInput({
|
|||||||
|
|
||||||
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);
|
onFileChange([...files, ...newFiles]);
|
||||||
showValidationErrors(errors);
|
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles]);
|
|
||||||
}
|
|
||||||
// Reset input so same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFile = (fileToRemove: File) => {
|
const handleRemoveFile = (fileToRemove: File) => {
|
||||||
@@ -233,14 +148,13 @@ export function ChatInput({
|
|||||||
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);
|
if (imageFiles.length > 0) {
|
||||||
showValidationErrors(errors);
|
onFileChange([...files, ...imageFiles]);
|
||||||
if (validFiles.length > 0) {
|
|
||||||
onFileChange([...files, ...validFiles]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,10 +178,7 @@ export function ChatInput({
|
|||||||
{/* File previews */}
|
{/* File previews */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<FilePreviewList
|
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
||||||
files={files}
|
|
||||||
onRemoveFile={handleRemoveFile}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -310,50 +221,6 @@ export function ChatInput({
|
|||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={onToggleHistory}
|
onToggleHistory={onToggleHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowThemeWarning(true)}
|
|
||||||
tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"}
|
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{drawioUi === "min" ? (
|
|
||||||
<PenTool className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
|
|
||||||
<Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Switch Theme?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Switching themes will reload the diagram editor and clear any unsaved changes.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowThemeWarning(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
onClearChat();
|
|
||||||
onToggleDrawioUi();
|
|
||||||
setShowThemeWarning(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Switch Theme
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right actions */}
|
{/* Right actions */}
|
||||||
@@ -385,12 +252,8 @@ export function ChatInput({
|
|||||||
<SaveDialog
|
<SaveDialog
|
||||||
open={showSaveDialog}
|
open={showSaveDialog}
|
||||||
onOpenChange={setShowSaveDialog}
|
onOpenChange={setShowSaveDialog}
|
||||||
onSave={(filename, format) =>
|
onSave={(filename, format) => saveDiagramToFile(filename, format)}
|
||||||
saveDiagramToFile(filename, format, sessionId)
|
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
||||||
}
|
|
||||||
defaultFilename={`diagram-${new Date()
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
@@ -422,9 +285,7 @@ export function ChatInput({
|
|||||||
disabled={isDisabled || !input.trim()}
|
disabled={isDisabled || !input.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||||
aria-label={
|
aria-label={isDisabled ? "Sending..." : "Send message"}
|
||||||
isDisabled ? "Sending..." : "Send message"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isDisabled ? (
|
{isDisabled ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -438,6 +299,7 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
import { useRef, useEffect, useState, useCallback } from "react";
|
import { useRef, useEffect, useState, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import ExamplePanel from "./chat-example-panel";
|
import ExamplePanel from "./chat-example-panel";
|
||||||
import { UIMessage } from "ai";
|
import { UIMessage } from "ai";
|
||||||
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
||||||
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react";
|
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, RotateCcw, Pencil } from "lucide-react";
|
||||||
import { CodeBlock } from "./code-block";
|
import { CodeBlock } from "./code-block";
|
||||||
|
|
||||||
interface EditPair {
|
interface EditPair {
|
||||||
@@ -65,18 +64,18 @@ const getMessageTextContent = (message: UIMessage): string => {
|
|||||||
|
|
||||||
interface ChatMessageDisplayProps {
|
interface ChatMessageDisplayProps {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
|
error?: Error | null;
|
||||||
setInput: (input: string) => void;
|
setInput: (input: string) => void;
|
||||||
setFiles: (files: File[]) => void;
|
setFiles: (files: File[]) => void;
|
||||||
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({
|
||||||
messages,
|
messages,
|
||||||
|
error,
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
sessionId,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditMessage,
|
onEditMessage,
|
||||||
}: ChatMessageDisplayProps) {
|
}: ChatMessageDisplayProps) {
|
||||||
@@ -89,7 +88,6 @@ export function ChatMessageDisplay({
|
|||||||
);
|
);
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||||
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
|
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
|
||||||
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({});
|
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
const [editText, setEditText] = useState<string>("");
|
const [editText, setEditText] = useState<string>("");
|
||||||
|
|
||||||
@@ -105,34 +103,6 @@ export function ChatMessageDisplay({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
|
|
||||||
// Toggle off if already selected
|
|
||||||
if (feedback[messageId] === value) {
|
|
||||||
setFeedback((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[messageId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFeedback((prev) => ({ ...prev, [messageId]: value }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch("/api/log-feedback", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
messageId,
|
|
||||||
feedback: value,
|
|
||||||
sessionId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to log feedback:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisplayChart = useCallback(
|
const handleDisplayChart = useCallback(
|
||||||
(xml: string) => {
|
(xml: string) => {
|
||||||
const currentXml = xml || "";
|
const currentXml = xml || "";
|
||||||
@@ -282,11 +252,11 @@ export function ChatMessageDisplay({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
<ScrollArea className="h-full px-4 scrollbar-thin">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||||
) : (
|
) : (
|
||||||
<div className="py-4 px-4 space-y-4">
|
<div className="py-4 space-y-4">
|
||||||
{messages.map((message, messageIndex) => {
|
{messages.map((message, messageIndex) => {
|
||||||
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
||||||
const isLastAssistantMessage = message.role === "assistant" && (
|
const isLastAssistantMessage = message.role === "assistant" && (
|
||||||
@@ -301,7 +271,7 @@ export function ChatMessageDisplay({
|
|||||||
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 ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||||
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
||||||
>
|
>
|
||||||
{message.role === "user" && userMessageText && !isEditing && (
|
{message.role === "user" && userMessageText && !isEditing && (
|
||||||
@@ -334,7 +304,7 @@ export function ChatMessageDisplay({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-[85%] min-w-0">
|
<div className="max-w-[85%]">
|
||||||
{/* 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">
|
||||||
@@ -390,8 +360,6 @@ export function ChatMessageDisplay({
|
|||||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
|
||||||
: message.role === "system"
|
|
||||||
? "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={() => {
|
||||||
@@ -406,8 +374,8 @@ export function ChatMessageDisplay({
|
|||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return (
|
return (
|
||||||
<div key={index} className="prose prose-sm dark:prose-invert max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div key={index} className="whitespace-pre-wrap break-words">
|
||||||
<ReactMarkdown>{part.text}</ReactMarkdown>
|
{part.text}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "file":
|
case "file":
|
||||||
@@ -468,32 +436,6 @@ export function ChatMessageDisplay({
|
|||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
|
||||||
{/* Thumbs up */}
|
|
||||||
<button
|
|
||||||
onClick={() => submitFeedback(message.id, "good")}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
|
||||||
feedback[message.id] === "good"
|
|
||||||
? "text-green-600 bg-green-100"
|
|
||||||
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
|
|
||||||
}`}
|
|
||||||
title="Good response"
|
|
||||||
>
|
|
||||||
<ThumbsUp className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
{/* Thumbs down */}
|
|
||||||
<button
|
|
||||||
onClick={() => submitFeedback(message.id, "bad")}
|
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
|
||||||
feedback[message.id] === "bad"
|
|
||||||
? "text-red-600 bg-red-100"
|
|
||||||
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
|
|
||||||
}`}
|
|
||||||
title="Bad response"
|
|
||||||
>
|
|
||||||
<ThumbsDown className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -502,6 +444,11 @@ export function ChatMessageDisplay({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
<span className="font-medium">Error:</span> {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import type React from "react";
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { FaGithub } from "react-icons/fa";
|
import { FaGithub } from "react-icons/fa";
|
||||||
import {
|
import { PanelRightClose, PanelRightOpen, CheckCircle } from "lucide-react";
|
||||||
PanelRightClose,
|
|
||||||
PanelRightOpen,
|
|
||||||
Settings,
|
|
||||||
CheckCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
@@ -20,24 +15,15 @@ import { ChatMessageDisplay } from "./chat-message-display";
|
|||||||
import { useDiagram } from "@/contexts/diagram-context";
|
import { useDiagram } from "@/contexts/diagram-context";
|
||||||
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import {
|
|
||||||
SettingsDialog,
|
|
||||||
STORAGE_ACCESS_CODE_KEY,
|
|
||||||
} from "@/components/settings-dialog";
|
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onToggleVisibility: () => void;
|
onToggleVisibility: () => void;
|
||||||
drawioUi: "min" | "sketch";
|
|
||||||
onToggleDrawioUi: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({
|
export default function ChatPanel({
|
||||||
isVisible,
|
isVisible,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
drawioUi,
|
|
||||||
onToggleDrawioUi,
|
|
||||||
}: ChatPanelProps) {
|
}: ChatPanelProps) {
|
||||||
const {
|
const {
|
||||||
loadDiagram: onDisplayChart,
|
loadDiagram: onDisplayChart,
|
||||||
@@ -74,22 +60,8 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
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 [accessCodeRequired, setAccessCodeRequired] = useState(false);
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [streamingError, setStreamingError] = useState<Error | null>(null);
|
||||||
// Check if access code is required on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/config")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
|
|
||||||
.catch(() => setAccessCodeRequired(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Generate a unique session ID for Langfuse tracing
|
|
||||||
const [sessionId, setSessionId] = useState(
|
|
||||||
() => `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());
|
||||||
@@ -100,83 +72,88 @@ export default function ChatPanel({
|
|||||||
chartXMLRef.current = chartXML;
|
chartXMLRef.current = chartXML;
|
||||||
}, [chartXML]);
|
}, [chartXML]);
|
||||||
|
|
||||||
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
const {
|
||||||
useChat({
|
messages,
|
||||||
transport: new DefaultChatTransport({
|
sendMessage,
|
||||||
api: "/api/chat",
|
addToolResult,
|
||||||
}),
|
status,
|
||||||
async onToolCall({ toolCall }) {
|
error,
|
||||||
if (toolCall.toolName === "display_diagram") {
|
setMessages,
|
||||||
const { xml } = toolCall.input as { xml: string };
|
stop,
|
||||||
|
} = useChat({
|
||||||
|
transport: new DefaultChatTransport({
|
||||||
|
api: "/api/chat",
|
||||||
|
}),
|
||||||
|
async onToolCall({ toolCall }) {
|
||||||
|
if (toolCall.toolName === "display_diagram") {
|
||||||
|
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 {
|
||||||
|
addToolResult({
|
||||||
|
tool: "display_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: "Successfully displayed the diagram.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (toolCall.toolName === "edit_diagram") {
|
||||||
|
const { edits } = toolCall.input as {
|
||||||
|
edits: Array<{ search: string; replace: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentXml = "";
|
||||||
|
try {
|
||||||
|
console.log("[edit_diagram] Starting...");
|
||||||
|
// Use chartXML from ref directly - more reliable than export
|
||||||
|
// especially on Vercel where DrawIO iframe may have latency issues
|
||||||
|
// Using ref to avoid stale closure in callback
|
||||||
|
const cachedXML = chartXMLRef.current;
|
||||||
|
if (cachedXML) {
|
||||||
|
currentXml = cachedXML;
|
||||||
|
console.log(
|
||||||
|
"[edit_diagram] Using cached chartXML, length:",
|
||||||
|
currentXml.length
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
addToolResult({
|
// Fallback to export only if no cached XML
|
||||||
tool: "display_diagram",
|
console.log(
|
||||||
toolCallId: toolCall.toolCallId,
|
"[edit_diagram] No cached XML, fetching from DrawIO..."
|
||||||
output: "Successfully displayed the diagram.",
|
);
|
||||||
});
|
currentXml = await onFetchChart(false);
|
||||||
|
console.log(
|
||||||
|
"[edit_diagram] Got XML from export, length:",
|
||||||
|
currentXml.length
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (toolCall.toolName === "edit_diagram") {
|
|
||||||
const { edits } = toolCall.input as {
|
|
||||||
edits: Array<{ search: string; replace: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentXml = "";
|
const { replaceXMLParts } = await import("@/lib/utils");
|
||||||
try {
|
const editedXml = replaceXMLParts(currentXml, edits);
|
||||||
console.log("[edit_diagram] Starting...");
|
|
||||||
// Use chartXML from ref directly - more reliable than export
|
|
||||||
// especially on Vercel where DrawIO iframe may have latency issues
|
|
||||||
// Using ref to avoid stale closure in callback
|
|
||||||
const cachedXML = chartXMLRef.current;
|
|
||||||
if (cachedXML) {
|
|
||||||
currentXml = cachedXML;
|
|
||||||
console.log(
|
|
||||||
"[edit_diagram] Using cached chartXML, length:",
|
|
||||||
currentXml.length
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Fallback to export only if no cached XML
|
|
||||||
console.log(
|
|
||||||
"[edit_diagram] No cached XML, fetching from DrawIO..."
|
|
||||||
);
|
|
||||||
currentXml = await onFetchChart(false);
|
|
||||||
console.log(
|
|
||||||
"[edit_diagram] Got XML from export, length:",
|
|
||||||
currentXml.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { replaceXMLParts } = await import("@/lib/utils");
|
onDisplayChart(editedXml);
|
||||||
const editedXml = replaceXMLParts(currentXml, edits);
|
|
||||||
|
|
||||||
onDisplayChart(editedXml);
|
addToolResult({
|
||||||
|
tool: "edit_diagram",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
||||||
|
});
|
||||||
|
console.log("[edit_diagram] Success");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[edit_diagram] Failed:", error);
|
||||||
|
|
||||||
addToolResult({
|
const errorMessage =
|
||||||
tool: "edit_diagram",
|
error instanceof Error ? error.message : String(error);
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
|
|
||||||
});
|
|
||||||
console.log("[edit_diagram] Success");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[edit_diagram] Failed:", error);
|
|
||||||
|
|
||||||
const errorMessage =
|
addToolResult({
|
||||||
error instanceof Error
|
tool: "edit_diagram",
|
||||||
? error.message
|
toolCallId: toolCall.toolCallId,
|
||||||
: String(error);
|
output: `Edit failed: ${errorMessage}
|
||||||
|
|
||||||
addToolResult({
|
|
||||||
tool: "edit_diagram",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
output: `Edit failed: ${errorMessage}
|
|
||||||
|
|
||||||
Current diagram XML:
|
Current diagram XML:
|
||||||
\`\`\`xml
|
\`\`\`xml
|
||||||
@@ -184,34 +161,73 @@ ${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) => {
|
|
||||||
// Silence access code error in console since it's handled by UI
|
|
||||||
if (!error.message.includes("Invalid or missing access code")) {
|
|
||||||
console.error("Chat error:", error);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Chat error:", error);
|
||||||
|
setStreamingError(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Add system message for error so it can be cleared
|
// Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503)
|
||||||
setMessages((currentMessages) => {
|
// This catches cases where onError doesn't fire because headers were already sent
|
||||||
const errorMessage = {
|
const lastMessageCountRef = useRef(0);
|
||||||
id: `error-${Date.now()}`,
|
const lastMessagePartsRef = useRef(0);
|
||||||
role: "system" as const,
|
|
||||||
content: error.message,
|
|
||||||
parts: [{ type: "text" as const, text: error.message }],
|
|
||||||
};
|
|
||||||
return [...currentMessages, errorMessage];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid or missing access code")) {
|
useEffect(() => {
|
||||||
// Show settings button and open dialog to help user fix it
|
// Clear streaming error when status changes to ready
|
||||||
setAccessCodeRequired(true);
|
if (status === "ready") {
|
||||||
setShowSettingsDialog(true);
|
setStreamingError(null);
|
||||||
}
|
lastMessageCountRef.current = 0;
|
||||||
},
|
lastMessagePartsRef.current = 0;
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "streaming") return;
|
||||||
|
|
||||||
|
const STALL_TIMEOUT_MS = 15000; // 15 seconds without any update
|
||||||
|
|
||||||
|
// Capture current state BEFORE setting timeout
|
||||||
|
// This way we compare against values at the time timeout was set
|
||||||
|
const currentPartsCount = messages.reduce(
|
||||||
|
(acc, msg) => acc + (msg.parts?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const capturedMessageCount = messages.length;
|
||||||
|
const capturedPartsCount = currentPartsCount;
|
||||||
|
|
||||||
|
// Update refs immediately so next effect run has fresh values
|
||||||
|
lastMessageCountRef.current = messages.length;
|
||||||
|
lastMessagePartsRef.current = currentPartsCount;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// Re-count parts at timeout time
|
||||||
|
const newPartsCount = messages.reduce(
|
||||||
|
(acc, msg) => acc + (msg.parts?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no change since timeout was set, stream has stalled
|
||||||
|
if (
|
||||||
|
messages.length === capturedMessageCount &&
|
||||||
|
newPartsCount === capturedPartsCount
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
"[Streaming Timeout] No activity for 15s - forcing error state"
|
||||||
|
);
|
||||||
|
setStreamingError(
|
||||||
|
new Error(
|
||||||
|
"Connection lost. The AI service may be temporarily unavailable. Please try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
stop(); // Allow user to retry by transitioning status to "ready"
|
||||||
|
}
|
||||||
|
}, STALL_TIMEOUT_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [status, messages, stop]);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -223,8 +239,13 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
|
|
||||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const isProcessing = status === "streaming" || status === "submitted";
|
// Allow retry if there's a streaming error (workaround for stop() not transitioning status)
|
||||||
|
const isProcessing =
|
||||||
|
(status === "streaming" || status === "submitted") &&
|
||||||
|
!streamingError;
|
||||||
if (input.trim() && !isProcessing) {
|
if (input.trim() && !isProcessing) {
|
||||||
|
// Clear any previous streaming error before starting new request
|
||||||
|
setStreamingError(null);
|
||||||
try {
|
try {
|
||||||
let chartXml = await onFetchChart();
|
let chartXml = await onFetchChart();
|
||||||
chartXml = formatXML(chartXml);
|
chartXml = formatXML(chartXml);
|
||||||
@@ -256,17 +277,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
const messageIndex = messages.length;
|
const messageIndex = messages.length;
|
||||||
xmlSnapshotsRef.current.set(messageIndex, chartXml);
|
xmlSnapshotsRef.current.set(messageIndex, chartXml);
|
||||||
|
|
||||||
const accessCode =
|
|
||||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
|
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ parts },
|
{ parts },
|
||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: chartXml,
|
xml: chartXml,
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
"x-access-code": accessCode,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -347,7 +362,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: savedXml,
|
xml: savedXml,
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -404,7 +418,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
{
|
{
|
||||||
body: {
|
body: {
|
||||||
xml: savedXml,
|
xml: savedXml,
|
||||||
sessionId,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -438,12 +451,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
|
|
||||||
// Full view
|
// Full view
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
|
||||||
<Toaster
|
|
||||||
position="bottom-center"
|
|
||||||
richColors
|
|
||||||
style={{ position: "absolute" }}
|
|
||||||
/>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="px-5 py-4 border-b border-border/50">
|
<header className="px-5 py-4 border-b border-border/50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -467,7 +475,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 6 hours of stability. If issues persist, please report on GitHub."
|
tooltipContent="Recent generation failures were caused by our AI provider's infrastructure issue, not the app code. After extensive debugging, I've switched providers and observed 30+ minutes of stability. If issues persist, please report on GitHub."
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-green-500 hover:text-green-600"
|
className="h-6 w-6 text-green-500 hover:text-green-600"
|
||||||
@@ -484,17 +492,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
>
|
>
|
||||||
<FaGithub className="w-5 h-5" />
|
<FaGithub className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
{accessCodeRequired && (
|
|
||||||
<ButtonWithTooltip
|
|
||||||
tooltipContent="Settings"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowSettingsDialog(true)}
|
|
||||||
className="hover:bg-accent"
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</ButtonWithTooltip>
|
|
||||||
)}
|
|
||||||
<ButtonWithTooltip
|
<ButtonWithTooltip
|
||||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -509,12 +506,12 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<main className="flex-1 w-full overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<ChatMessageDisplay
|
<ChatMessageDisplay
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
error={error || streamingError}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={handleFileChange}
|
setFiles={handleFileChange}
|
||||||
sessionId={sessionId}
|
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onEditMessage={handleEditMessage}
|
onEditMessage={handleEditMessage}
|
||||||
/>
|
/>
|
||||||
@@ -530,28 +527,15 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
onClearChat={() => {
|
onClearChat={() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
clearDiagram();
|
clearDiagram();
|
||||||
setSessionId(
|
|
||||||
`session-${Date.now()}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2, 9)}`
|
|
||||||
);
|
|
||||||
xmlSnapshotsRef.current.clear();
|
xmlSnapshotsRef.current.clear();
|
||||||
}}
|
}}
|
||||||
files={files}
|
files={files}
|
||||||
onFileChange={handleFileChange}
|
onFileChange={handleFileChange}
|
||||||
showHistory={showHistory}
|
showHistory={showHistory}
|
||||||
onToggleHistory={setShowHistory}
|
onToggleHistory={setShowHistory}
|
||||||
sessionId={sessionId}
|
error={error || streamingError}
|
||||||
error={error}
|
|
||||||
drawioUi={drawioUi}
|
|
||||||
onToggleDrawioUi={onToggleDrawioUi}
|
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<SettingsDialog
|
|
||||||
open={showSettingsDialog}
|
|
||||||
onOpenChange={setShowSettingsDialog}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ErrorToastProps {
|
|
||||||
message: React.ReactNode;
|
|
||||||
onDismiss: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
onDismiss();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={onDismiss}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
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">
|
|
||||||
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
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"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code";
|
|
||||||
|
|
||||||
export function SettingsDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: SettingsDialogProps) {
|
|
||||||
const [accessCode, setAccessCode] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
|
|
||||||
setAccessCode(storedCode);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim());
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Settings</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure your access settings.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
||||||
Access Code
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={accessCode}
|
|
||||||
onChange={(e) => setAccessCode(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Enter access code"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-[0.8rem] text-muted-foreground">
|
|
||||||
Required if the server has enabled access control.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave}>Save</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function ResizablePanelGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
data-slot="resizable-panel-group"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResizablePanel({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
|
||||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResizableHandle({
|
|
||||||
withHandle,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
|
||||||
withHandle?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
|
||||||
data-slot="resizable-handle"
|
|
||||||
className={cn(
|
|
||||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{withHandle && (
|
|
||||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
|
||||||
<GripVerticalIcon className="size-2.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
|
||||||
@@ -18,7 +18,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 !overflow-x-hidden"
|
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface DiagramContextType {
|
|||||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||||
handleDiagramExport: (data: any) => void;
|
handleDiagramExport: (data: any) => void;
|
||||||
clearDiagram: () => void;
|
clearDiagram: () => void;
|
||||||
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void;
|
saveDiagramToFile: (filename: string, format: ExportFormat) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||||
@@ -107,7 +107,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setDiagramHistory([]);
|
setDiagramHistory([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => {
|
const saveDiagramToFile = (filename: string, format: ExportFormat) => {
|
||||||
if (!drawioRef.current) {
|
if (!drawioRef.current) {
|
||||||
console.warn("Draw.io editor not ready");
|
console.warn("Draw.io editor not ready");
|
||||||
return;
|
return;
|
||||||
@@ -145,9 +145,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
extension = ".svg";
|
extension = ".svg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log save event to Langfuse (flags the trace)
|
|
||||||
logSaveToLangfuse(filename, format, sessionId);
|
|
||||||
|
|
||||||
// Handle download
|
// Handle download
|
||||||
let url: string;
|
let url: string;
|
||||||
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
|
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
|
||||||
@@ -177,19 +174,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
|||||||
drawioRef.current.exportDiagram({ format: drawioFormat });
|
drawioRef.current.exportDiagram({ format: drawioFormat });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
|
||||||
const logSaveToLangfuse = async (filename: string, format: string, sessionId?: string) => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/log-save", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ filename, format, sessionId }),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to log save to Langfuse:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiagramContext.Provider
|
<DiagramContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
# AI Provider Configuration
|
|
||||||
|
|
||||||
This guide explains how to configure different AI model providers for next-ai-draw-io.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env.local`
|
|
||||||
2. Set your API key for your chosen provider
|
|
||||||
3. Set `AI_MODEL` to your desired model
|
|
||||||
4. Run `npm run dev`
|
|
||||||
|
|
||||||
## Supported Providers
|
|
||||||
|
|
||||||
### Google Gemini
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gemini-2.0-flash
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=your_api_key
|
|
||||||
AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint (for OpenAI-compatible services):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_BASE_URL=https://your-custom-endpoint/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Anthropic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_API_KEY=your_api_key
|
|
||||||
AI_MODEL=claude-sonnet-4-5-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ANTHROPIC_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### DeepSeek
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_API_KEY=your_api_key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEEPSEEK_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Azure OpenAI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_API_KEY=your_api_key
|
|
||||||
AI_MODEL=your-deployment-name
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AZURE_BASE_URL=https://your-resource.openai.azure.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### AWS Bedrock
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AWS_REGION=us-west-2
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
|
||||||
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: On AWS (Amplify, Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
|
|
||||||
|
|
||||||
### OpenRouter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_API_KEY=your_api_key
|
|
||||||
AI_MODEL=anthropic/claude-sonnet-4
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENROUTER_BASE_URL=https://your-custom-endpoint
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ollama (Local)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=ollama
|
|
||||||
AI_MODEL=llama3.2
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional custom URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection
|
|
||||||
|
|
||||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
|
||||||
|
|
||||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AI_PROVIDER=google # or: openai, anthropic, deepseek, azure, bedrock, openrouter, ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
## Model Capability Requirements
|
|
||||||
|
|
||||||
This task requires exceptionally strong model capabilities, as it involves generating long-form text with strict formatting constraints (draw.io XML).
|
|
||||||
|
|
||||||
**Recommended models**:
|
|
||||||
|
|
||||||
- Claude Sonnet 4.5 / Opus 4.5
|
|
||||||
|
|
||||||
**Note on Ollama**: While Ollama is supported as a provider, it's generally not practical for this use case unless you're running high-capability models like DeepSeek R1 or Qwen3-235B locally.
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
- **Best experience**: Use models with vision support (GPT-4o, Claude, Gemini) for image-to-diagram features
|
|
||||||
- **Budget-friendly**: DeepSeek offers competitive pricing
|
|
||||||
- **Privacy**: Use Ollama for fully local, offline operation (requires powerful hardware)
|
|
||||||
- **Flexibility**: OpenRouter provides access to many models through a single API
|
|
||||||
@@ -41,12 +41,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# DeepSeek Configuration
|
# DeepSeek Configuration
|
||||||
# DEEPSEEK_API_KEY=sk-...
|
# DEEPSEEK_API_KEY=sk-...
|
||||||
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
|
||||||
|
|
||||||
# Langfuse Observability (Optional)
|
|
||||||
# Enable LLM tracing and analytics - https://langfuse.com
|
|
||||||
# LANGFUSE_PUBLIC_KEY=pk-lf-...
|
|
||||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
|
||||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
|
||||||
|
|
||||||
# Access Control (Optional)
|
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { LangfuseSpanProcessor } from '@langfuse/otel';
|
|
||||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
||||||
|
|
||||||
export function register() {
|
|
||||||
// Skip telemetry if Langfuse env vars are not configured
|
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
|
||||||
console.warn('[Langfuse] Environment variables not configured - telemetry disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const langfuseSpanProcessor = new LangfuseSpanProcessor({
|
|
||||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
|
||||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
|
||||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
|
||||||
// Filter out Next.js HTTP request spans so AI SDK spans become root traces
|
|
||||||
shouldExportSpan: ({ otelSpan }) => {
|
|
||||||
const spanName = otelSpan.name;
|
|
||||||
// Skip Next.js HTTP infrastructure spans
|
|
||||||
if (spanName.startsWith('POST /') ||
|
|
||||||
spanName.startsWith('GET /') ||
|
|
||||||
spanName.includes('BaseServer') ||
|
|
||||||
spanName.includes('handleRequest')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tracerProvider = new NodeTracerProvider({
|
|
||||||
spanProcessors: [langfuseSpanProcessor],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register globally so AI SDK's telemetry also uses this processor
|
|
||||||
tracerProvider.register();
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { observe, updateActiveTrace } from '@langfuse/tracing';
|
|
||||||
import { LangfuseClient } from '@langfuse/client';
|
|
||||||
import * as api from '@opentelemetry/api';
|
|
||||||
|
|
||||||
// Singleton LangfuseClient instance for direct API calls
|
|
||||||
let langfuseClient: LangfuseClient | null = null;
|
|
||||||
|
|
||||||
export function getLangfuseClient(): LangfuseClient | null {
|
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!langfuseClient) {
|
|
||||||
langfuseClient = new LangfuseClient({
|
|
||||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
|
||||||
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
|
||||||
baseUrl: process.env.LANGFUSE_BASEURL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return langfuseClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Langfuse is configured
|
|
||||||
export function isLangfuseEnabled(): boolean {
|
|
||||||
return !!process.env.LANGFUSE_PUBLIC_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trace with input data at the start of request
|
|
||||||
export function setTraceInput(params: {
|
|
||||||
input: string;
|
|
||||||
sessionId?: string;
|
|
||||||
userId?: string;
|
|
||||||
}) {
|
|
||||||
if (!isLangfuseEnabled()) return;
|
|
||||||
|
|
||||||
updateActiveTrace({
|
|
||||||
name: 'chat',
|
|
||||||
input: params.input,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
userId: params.userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trace with output and end the span
|
|
||||||
export function setTraceOutput(output: string, usage?: { promptTokens?: number; completionTokens?: number }) {
|
|
||||||
if (!isLangfuseEnabled()) return;
|
|
||||||
|
|
||||||
updateActiveTrace({ output });
|
|
||||||
|
|
||||||
const activeSpan = api.trace.getActiveSpan();
|
|
||||||
if (activeSpan) {
|
|
||||||
// Manually set usage attributes since AI SDK Bedrock streaming doesn't provide them
|
|
||||||
if (usage?.promptTokens) {
|
|
||||||
activeSpan.setAttribute('ai.usage.promptTokens', usage.promptTokens);
|
|
||||||
activeSpan.setAttribute('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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get telemetry config for streamText
|
|
||||||
export function getTelemetryConfig(params: {
|
|
||||||
sessionId?: string;
|
|
||||||
userId?: string;
|
|
||||||
}) {
|
|
||||||
if (!isLangfuseEnabled()) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
isEnabled: true,
|
|
||||||
// Disable automatic input recording to avoid uploading large base64 images to Langfuse media
|
|
||||||
// User text input is recorded manually via setTraceInput
|
|
||||||
recordInputs: false,
|
|
||||||
recordOutputs: true,
|
|
||||||
metadata: {
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
userId: params.userId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap a handler with Langfuse observe
|
|
||||||
export function wrapWithObserve<T>(
|
|
||||||
handler: (req: Request) => Promise<T>
|
|
||||||
): (req: Request) => Promise<T> {
|
|
||||||
if (!isLangfuseEnabled()) {
|
|
||||||
return handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
return observe(handler, { name: 'chat', endOnExit: false });
|
|
||||||
}
|
|
||||||
843
package-lock.json
generated
843
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,8 @@
|
|||||||
"@ai-sdk/openai": "^2.0.19",
|
"@ai-sdk/openai": "^2.0.19",
|
||||||
"@ai-sdk/react": "^2.0.22",
|
"@ai-sdk/react": "^2.0.22",
|
||||||
"@aws-sdk/credential-providers": "^3.943.0",
|
"@aws-sdk/credential-providers": "^3.943.0",
|
||||||
"@langfuse/client": "^4.4.9",
|
|
||||||
"@langfuse/otel": "^4.4.4",
|
|
||||||
"@langfuse/tracing": "^4.4.9",
|
|
||||||
"@next/third-parties": "^16.0.6",
|
"@next/third-parties": "^16.0.6",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -45,17 +41,13 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-drawio": "^1.0.3",
|
"react-drawio": "^1.0.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"react-resizable-panels": "^3.0.6",
|
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user