mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-03 06:42:27 +08:00
Compare commits
15 Commits
fix/preven
...
feat/enhan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30bf92fbbb | ||
|
|
dd27d034e2 | ||
|
|
9e781005af | ||
|
|
fe1aa2747e | ||
|
|
7205896c8c | ||
|
|
4e32a094b1 | ||
|
|
96a1111654 | ||
|
|
3f35c52527 | ||
|
|
0af5229477 | ||
|
|
3fb349fb3e | ||
|
|
ed29e32ba3 | ||
|
|
4cd78dc561 | ||
|
|
e0c5d966e3 | ||
|
|
33471d5b3a | ||
|
|
3ef9908df7 |
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
|
||||
|
||||
- AWS Bedrock (default)
|
||||
- OpenAI / OpenAI-compatible APIs (via `OPENAI_BASE_URL`)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,6 +89,12 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
- OpenRouter
|
||||
- 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.
|
||||
|
||||
## Getting Started
|
||||
@@ -143,8 +149,11 @@ 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_MODEL` to the specific model you want to use
|
||||
- Add the required API keys for your provider
|
||||
- `ACCESS_CODE_LIST`: Optional access password(s), can be comma-separated for multiple passwords.
|
||||
|
||||
See the [Multi-Provider Support](#multi-provider-support) section above for provider-specific configuration examples.
|
||||
> 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 [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
||||
|
||||
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(默认)
|
||||
- OpenAI / OpenAI兼容API(通过 `OPENAI_BASE_URL`)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- OpenRouter
|
||||
- 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架构图,这是最佳选择。
|
||||
|
||||
## 快速开始
|
||||
@@ -143,8 +149,11 @@ cp env.example .env.local
|
||||
- 将 `AI_PROVIDER` 设置为您选择的提供商(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- 将 `AI_MODEL` 设置为您要使用的特定模型
|
||||
- 添加您的提供商所需的API密钥
|
||||
- `ACCESS_CODE_LIST` 访问密码,可选,可以使用逗号隔开多个密码。
|
||||
|
||||
请参阅上面的[多提供商支持](#多提供商支持)部分了解特定提供商的配置示例。
|
||||
> 警告:如果不填写 `ACCESS_CODE_LIST`,则任何人都可以直接使用你部署后的网站,可能会导致你的 token 被急速消耗完毕,建议填写此选项。
|
||||
|
||||
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
|
||||
|
||||
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(デフォルト)
|
||||
- OpenAI / OpenAI互換API(`OPENAI_BASE_URL`経由)
|
||||
- OpenAI
|
||||
- Anthropic
|
||||
- Google AI
|
||||
- Azure OpenAI
|
||||
@@ -89,6 +89,12 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- OpenRouter
|
||||
- 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アーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||
|
||||
## はじめに
|
||||
@@ -143,8 +149,11 @@ cp env.example .env.local
|
||||
- `AI_PROVIDER`を選択したプロバイダーに設定(bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek)
|
||||
- `AI_MODEL`を使用する特定のモデルに設定
|
||||
- プロバイダーに必要なAPIキーを追加
|
||||
- `ACCESS_CODE_LIST` アクセスパスワード(オプション)。カンマ区切りで複数のパスワードを指定できます。
|
||||
|
||||
プロバイダー固有の設定例については、上記の[マルチプロバイダーサポート](#マルチプロバイダーサポート)セクションを参照してください。
|
||||
> 警告:`ACCESS_CODE_LIST`を設定しない場合、誰でもデプロイされたサイトに直接アクセスできるため、トークンが急速に消費される可能性があります。このオプションを設定することをお勧めします。
|
||||
|
||||
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
|
||||
|
||||
4. 開発サーバーを起動:
|
||||
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||
import { getAIModel } from '@/lib/ai-providers';
|
||||
import { findCachedResponse } from '@/lib/cached-responses';
|
||||
import { setTraceInput, setTraceOutput, getTelemetryConfig, wrapWithObserve } from '@/lib/langfuse';
|
||||
import { getSystemPrompt } from '@/lib/system-prompts';
|
||||
import { z } from "zod";
|
||||
|
||||
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
|
||||
function isMinimalDiagram(xml: string): boolean {
|
||||
const stripped = xml.replace(/\s/g, '');
|
||||
@@ -31,7 +62,46 @@ function createCachedStreamResponse(xml: string): Response {
|
||||
|
||||
// Inner handler function
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
const { messages, xml } = await req.json();
|
||||
// Check for access code
|
||||
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 ===
|
||||
const isFirstMessage = messages.length === 1;
|
||||
@@ -154,28 +224,19 @@ ${lastMessageText}
|
||||
messages: allMessages,
|
||||
...(providerOptions && { providerOptions }),
|
||||
...(headers && { headers }),
|
||||
onFinish: ({ usage, providerMetadata, finishReason, text, toolCalls }) => {
|
||||
// Detect potential mid-stream failures (e.g., Bedrock 503 ServiceUnavailableException)
|
||||
// When this happens, usage is empty and providerMetadata is undefined
|
||||
const hasUsage = usage && Object.keys(usage).length > 0;
|
||||
if (!hasUsage) {
|
||||
console.error('[Stream Error] Empty usage detected - possible Bedrock 503 or mid-stream failure');
|
||||
console.error('[Stream Error] finishReason:', finishReason);
|
||||
console.error('[Stream Error] text received:', text?.substring(0, 200) || '(none)');
|
||||
console.error('[Stream Error] toolCalls:', toolCalls?.length || 0);
|
||||
// Log the user's last message for debugging
|
||||
const lastUserMsg = enhancedMessages.filter(m => m.role === 'user').pop();
|
||||
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));
|
||||
}
|
||||
// Langfuse telemetry config (returns undefined if not configured)
|
||||
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
|
||||
experimental_telemetry: getTelemetryConfig({ sessionId: validSessionId, userId }),
|
||||
}),
|
||||
onFinish: ({ text, usage, providerMetadata }) => {
|
||||
console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2));
|
||||
console.log('[Cache] Usage:', JSON.stringify(usage, null, 2));
|
||||
// Pass usage to Langfuse (Bedrock streaming doesn't auto-report tokens to telemetry)
|
||||
// AI SDK uses inputTokens/outputTokens, Langfuse expects promptTokens/completionTokens
|
||||
setTraceOutput(text, {
|
||||
promptTokens: usage?.inputTokens,
|
||||
completionTokens: usage?.outputTokens,
|
||||
});
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
@@ -221,13 +282,31 @@ Notes:
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit specific parts of the current diagram by replacing exact line matches. Use this tool to make targeted fixes without regenerating the entire XML.
|
||||
CRITICAL: Copy-paste the EXACT search pattern from the "Current diagram XML" in system context. Do NOT reorder attributes or reformat - the attribute order in draw.io XML varies and you MUST match it exactly.
|
||||
IMPORTANT: Keep edits concise:
|
||||
- COPY the exact mxCell line from the current XML (attribute order matters!)
|
||||
- Only include the lines that are changing, plus 1-2 surrounding lines for context if needed
|
||||
- Break large changes into multiple smaller edits
|
||||
- Each search must contain complete lines (never truncate mid-line)
|
||||
- First match only - be specific enough to target the right element`,
|
||||
|
||||
WHEN TO USE:
|
||||
- Changing text labels or values
|
||||
- Modifying colors, styles, or visual properties
|
||||
- Adding or removing individual elements (1-3 elements)
|
||||
- Repositioning specific elements
|
||||
- Any small, targeted modification
|
||||
|
||||
WHEN TO USE display_diagram INSTEAD:
|
||||
- Creating a new diagram from scratch
|
||||
- Major restructuring (reorganizing layout, changing diagram type)
|
||||
- Adding many new elements (more than 3)
|
||||
- After 3 failed edit_diagram attempts
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Copy-paste the EXACT search pattern from the "Current diagram XML" in system context
|
||||
2. Do NOT reorder attributes - attribute order in draw.io XML varies, you MUST match exactly
|
||||
3. Always include the element's id attribute for unique targeting
|
||||
4. Include complete lines (never truncate mid-line)
|
||||
5. For multiple changes, use separate edits in the array
|
||||
|
||||
ERROR RECOVERY:
|
||||
- If pattern not found, check attribute order matches current XML exactly
|
||||
- Retry up to 3 times with adjusted patterns
|
||||
- After 3 failures, use display_diagram instead`,
|
||||
inputSchema: z.object({
|
||||
edits: z.array(z.object({
|
||||
search: z.string().describe("EXACT lines copied from current XML (preserve attribute order!)"),
|
||||
@@ -239,51 +318,11 @@ IMPORTANT: Keep edits concise:
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
try {
|
||||
return await handleChatRequest(req);
|
||||
} catch (error) {
|
||||
@@ -291,3 +330,10 @@ export async function POST(req: Request) {
|
||||
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);
|
||||
}
|
||||
|
||||
9
app/api/config/route.ts
Normal file
9
app/api/config/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
103
app/api/log-feedback/route.ts
Normal file
103
app/api/log-feedback/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
65
app/api/log-save/route.ts
Normal file
65
app/api/log-save/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -152,6 +153,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for Radix ScrollArea viewport horizontal overflow */
|
||||
[data-slot="scroll-area-viewport"] > div {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { GoogleAnalytics } from "@next/third-parties/google";
|
||||
@@ -18,6 +18,13 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
weight: ["400", "500"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next AI Draw.io - AI-Powered Diagram Generator",
|
||||
description: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
|
||||
@@ -96,7 +103,6 @@ export default function RootLayout({
|
||||
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
|
||||
<Analytics />
|
||||
</body>
|
||||
{process.env.NEXT_PUBLIC_GA_ID && (
|
||||
|
||||
138
app/page.tsx
138
app/page.tsx
@@ -1,14 +1,27 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DrawIoEmbed } from "react-drawio";
|
||||
import ChatPanel from "@/components/chat-panel";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { Monitor } from "lucide-react";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels";
|
||||
|
||||
export default function Home() {
|
||||
const { drawioRef, handleDiagramExport } = useDiagram();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
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(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -20,16 +33,29 @@ export default function Home() {
|
||||
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(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
setIsChatVisible((prev) => !prev);
|
||||
toggleChatPanel();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Show confirmation dialog when user tries to leave the page
|
||||
@@ -37,61 +63,69 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
return '';
|
||||
return "";
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () =>
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background relative overflow-hidden">
|
||||
{/* Mobile warning overlay */}
|
||||
{isMobile && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||
<div className="text-center p-8 max-w-sm mx-auto animate-fade-in">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||
<Monitor className="w-8 h-8 text-primary" />
|
||||
<div className="h-screen bg-background relative overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
key={isMobile ? "mobile" : "desktop"}
|
||||
direction={isMobile ? "vertical" : "horizontal"}
|
||||
className="h-full"
|
||||
>
|
||||
{/* Draw.io Canvas */}
|
||||
<ResizablePanel defaultSize={isMobile ? 50 : 67} minSize={20}>
|
||||
<div className={`h-full relative ${isMobile ? "p-1" : "p-2"}`}>
|
||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||
<DrawIoEmbed
|
||||
key={drawioUi}
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
ui: drawioUi,
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-foreground mb-3">
|
||||
Desktop Required
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Draw.io Canvas */}
|
||||
<div
|
||||
className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
|
||||
>
|
||||
<div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
onExport={handleDiagramExport}
|
||||
urlParameters={{
|
||||
spin: true,
|
||||
libraries: false,
|
||||
saveAndExit: false,
|
||||
noExitBtn: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div
|
||||
className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
|
||||
>
|
||||
<div className="h-full py-2 pr-2">
|
||||
<ChatPanel
|
||||
isVisible={isChatVisible}
|
||||
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Chat Panel */}
|
||||
<ResizablePanel
|
||||
ref={chatPanelRef}
|
||||
defaultSize={isMobile ? 50 : 33}
|
||||
minSize={isMobile ? 20 : 15}
|
||||
maxSize={isMobile ? 80 : 50}
|
||||
collapsible={!isMobile}
|
||||
collapsedSize={isMobile ? 0 : 3}
|
||||
onCollapse={() => setIsChatVisible(false)}
|
||||
onExpand={() => setIsChatVisible(true)}
|
||||
>
|
||||
<div className={`h-full ${isMobile ? "p-1" : "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);
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResetWarningModal } from "@/components/reset-warning-modal";
|
||||
import { SaveDialog } from "@/components/save-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
@@ -12,12 +20,80 @@ import {
|
||||
Image as ImageIcon,
|
||||
History,
|
||||
Download,
|
||||
Paperclip,
|
||||
PenTool,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { FilePreviewList } from "./file-preview-list";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { HistoryDialog } from "@/components/history-dialog";
|
||||
import { ErrorToast } from "@/components/error-toast";
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_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 {
|
||||
input: string;
|
||||
@@ -29,7 +105,10 @@ interface ChatInputProps {
|
||||
onFileChange?: (files: File[]) => void;
|
||||
showHistory?: boolean;
|
||||
onToggleHistory?: (show: boolean) => void;
|
||||
sessionId?: string;
|
||||
error?: Error | null;
|
||||
drawioUi?: "min" | "sketch";
|
||||
onToggleDrawioUi?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -42,7 +121,10 @@ export function ChatInput({
|
||||
onFileChange = () => {},
|
||||
showHistory = false,
|
||||
onToggleHistory = () => {},
|
||||
sessionId,
|
||||
error = null,
|
||||
drawioUi = "min",
|
||||
onToggleDrawioUi = () => {},
|
||||
}: ChatInputProps) {
|
||||
const { diagramHistory, saveDiagramToFile } = useDiagram();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -50,13 +132,11 @@ export function ChatInput({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = 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")
|
||||
const isDisabled = (status === "streaming" || status === "submitted") && !error;
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
|
||||
}, [status, isDisabled]);
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error;
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -89,23 +169,20 @@ export function ChatInput({
|
||||
);
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
const imageFiles = await Promise.all(
|
||||
imageItems.map(async (item) => {
|
||||
const imageFiles = (await Promise.all(
|
||||
imageItems.map(async (item, index) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return null;
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
|
||||
{
|
||||
type: file.type,
|
||||
}
|
||||
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
|
||||
{ type: file.type }
|
||||
);
|
||||
})
|
||||
);
|
||||
)).filter((f): f is File => f !== null);
|
||||
|
||||
const validFiles = imageFiles.filter(
|
||||
(file): file is File => file !== null
|
||||
);
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||
showValidationErrors(errors);
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
}
|
||||
@@ -114,7 +191,15 @@ export function ChatInput({
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFiles = Array.from(e.target.files || []);
|
||||
onFileChange([...files, ...newFiles]);
|
||||
const { validFiles, errors } = validateFiles(newFiles, files.length);
|
||||
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) => {
|
||||
@@ -148,13 +233,14 @@ export function ChatInput({
|
||||
if (isDisabled) return;
|
||||
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
|
||||
const imageFiles = Array.from(droppedFiles).filter((file) =>
|
||||
file.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
onFileChange([...files, ...imageFiles]);
|
||||
const { validFiles, errors } = validateFiles(imageFiles, files.length);
|
||||
showValidationErrors(errors);
|
||||
if (validFiles.length > 0) {
|
||||
onFileChange([...files, ...validFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,7 +264,10 @@ export function ChatInput({
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
|
||||
<FilePreviewList
|
||||
files={files}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -221,6 +310,50 @@ export function ChatInput({
|
||||
showHistory={showHistory}
|
||||
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>
|
||||
|
||||
{/* Right actions */}
|
||||
@@ -252,8 +385,12 @@ export function ChatInput({
|
||||
<SaveDialog
|
||||
open={showSaveDialog}
|
||||
onOpenChange={setShowSaveDialog}
|
||||
onSave={(filename, format) => saveDiagramToFile(filename, format)}
|
||||
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
|
||||
onSave={(filename, format) =>
|
||||
saveDiagramToFile(filename, format, sessionId)
|
||||
}
|
||||
defaultFilename={`diagram-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}`}
|
||||
/>
|
||||
|
||||
<ButtonWithTooltip
|
||||
@@ -285,7 +422,9 @@ export function ChatInput({
|
||||
disabled={isDisabled || !input.trim()}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={isDisabled ? "Sending..." : "Send message"}
|
||||
aria-label={
|
||||
isDisabled ? "Sending..." : "Send message"
|
||||
}
|
||||
>
|
||||
{isDisabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -299,7 +438,6 @@ export function ChatInput({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import ExamplePanel from "./chat-example-panel";
|
||||
import { UIMessage } from "ai";
|
||||
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
|
||||
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, RotateCcw, Pencil } from "lucide-react";
|
||||
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
interface EditPair {
|
||||
@@ -64,18 +65,18 @@ const getMessageTextContent = (message: UIMessage): string => {
|
||||
|
||||
interface ChatMessageDisplayProps {
|
||||
messages: UIMessage[];
|
||||
error?: Error | null;
|
||||
setInput: (input: string) => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
sessionId?: string;
|
||||
onRegenerate?: (messageIndex: number) => void;
|
||||
onEditMessage?: (messageIndex: number, newText: string) => void;
|
||||
}
|
||||
|
||||
export function ChatMessageDisplay({
|
||||
messages,
|
||||
error,
|
||||
setInput,
|
||||
setFiles,
|
||||
sessionId,
|
||||
onRegenerate,
|
||||
onEditMessage,
|
||||
}: ChatMessageDisplayProps) {
|
||||
@@ -88,6 +89,7 @@ export function ChatMessageDisplay({
|
||||
);
|
||||
const [copiedMessageId, setCopiedMessageId] = 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 [editText, setEditText] = useState<string>("");
|
||||
|
||||
@@ -103,6 +105,34 @@ 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(
|
||||
(xml: string) => {
|
||||
const currentXml = xml || "";
|
||||
@@ -252,11 +282,11 @@ export function ChatMessageDisplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-4 scrollbar-thin">
|
||||
<ScrollArea className="h-full w-full scrollbar-thin">
|
||||
{messages.length === 0 ? (
|
||||
<ExamplePanel setInput={setInput} setFiles={setFiles} />
|
||||
) : (
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="py-4 px-4 space-y-4">
|
||||
{messages.map((message, messageIndex) => {
|
||||
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
|
||||
const isLastAssistantMessage = message.role === "assistant" && (
|
||||
@@ -271,7 +301,7 @@ export function ChatMessageDisplay({
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
|
||||
style={{ animationDelay: `${messageIndex * 50}ms` }}
|
||||
>
|
||||
{message.role === "user" && userMessageText && !isEditing && (
|
||||
@@ -304,7 +334,7 @@ export function ChatMessageDisplay({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[85%]">
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
{/* Edit mode for user messages */}
|
||||
{isEditing && message.role === "user" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -360,6 +390,8 @@ export function ChatMessageDisplay({
|
||||
className={`px-4 py-3 text-sm leading-relaxed ${
|
||||
message.role === "user"
|
||||
? "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"
|
||||
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
|
||||
onClick={() => {
|
||||
@@ -374,8 +406,12 @@ export function ChatMessageDisplay({
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div key={index} className="whitespace-pre-wrap break-words">
|
||||
{part.text}
|
||||
<div key={index} className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
|
||||
message.role === "user"
|
||||
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
|
||||
: "dark:prose-invert"
|
||||
}`}>
|
||||
<ReactMarkdown>{part.text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
case "file":
|
||||
@@ -436,6 +472,32 @@ export function ChatMessageDisplay({
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
@@ -444,11 +506,6 @@ export function ChatMessageDisplay({
|
||||
})}
|
||||
</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} />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,12 @@ import type React from "react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { PanelRightClose, PanelRightOpen, CheckCircle } from "lucide-react";
|
||||
import {
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -15,15 +20,26 @@ import { ChatMessageDisplay } from "./chat-message-display";
|
||||
import { useDiagram } from "@/contexts/diagram-context";
|
||||
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
|
||||
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
import {
|
||||
SettingsDialog,
|
||||
STORAGE_ACCESS_CODE_KEY,
|
||||
} from "@/components/settings-dialog";
|
||||
|
||||
interface ChatPanelProps {
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
drawioUi: "min" | "sketch";
|
||||
onToggleDrawioUi: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatPanel({
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
drawioUi,
|
||||
onToggleDrawioUi,
|
||||
isMobile = false,
|
||||
}: ChatPanelProps) {
|
||||
const {
|
||||
loadDiagram: onDisplayChart,
|
||||
@@ -60,8 +76,22 @@ export default function ChatPanel({
|
||||
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(false);
|
||||
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)
|
||||
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
|
||||
@@ -72,88 +102,83 @@ export default function ChatPanel({
|
||||
chartXMLRef.current = chartXML;
|
||||
}, [chartXML]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
addToolResult,
|
||||
status,
|
||||
error,
|
||||
setMessages,
|
||||
stop,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
}),
|
||||
async onToolCall({ toolCall }) {
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
const { xml } = toolCall.input as { xml: string };
|
||||
const { messages, sendMessage, addToolResult, status, error, setMessages } =
|
||||
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) {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
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
|
||||
);
|
||||
if (validationError) {
|
||||
addToolResult({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: validationError,
|
||||
});
|
||||
} 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
|
||||
);
|
||||
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 }>;
|
||||
};
|
||||
|
||||
const { replaceXMLParts } = await import("@/lib/utils");
|
||||
const editedXml = replaceXMLParts(currentXml, edits);
|
||||
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 {
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
onDisplayChart(editedXml);
|
||||
const { replaceXMLParts } = await import("@/lib/utils");
|
||||
const editedXml = replaceXMLParts(currentXml, edits);
|
||||
|
||||
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);
|
||||
onDisplayChart(editedXml);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
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({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Edit failed: ${errorMessage}
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
addToolResult({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Edit failed: ${errorMessage}
|
||||
|
||||
Current diagram XML:
|
||||
\`\`\`xml
|
||||
@@ -161,73 +186,34 @@ ${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
// Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503)
|
||||
// This catches cases where onError doesn't fire because headers were already sent
|
||||
const lastMessageCountRef = useRef(0);
|
||||
const lastMessagePartsRef = useRef(0);
|
||||
// Add system message for error so it can be cleared
|
||||
setMessages((currentMessages) => {
|
||||
const errorMessage = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
content: error.message,
|
||||
parts: [{ type: "text" as const, text: error.message }],
|
||||
};
|
||||
return [...currentMessages, errorMessage];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Clear streaming error when status changes to ready
|
||||
if (status === "ready") {
|
||||
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]);
|
||||
if (error.message.includes("Invalid or missing access code")) {
|
||||
// Show settings button and open dialog to help user fix it
|
||||
setAccessCodeRequired(true);
|
||||
setShowSettingsDialog(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -239,13 +225,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
// Allow retry if there's a streaming error (workaround for stop() not transitioning status)
|
||||
const isProcessing =
|
||||
(status === "streaming" || status === "submitted") &&
|
||||
!streamingError;
|
||||
const isProcessing = status === "streaming" || status === "submitted";
|
||||
if (input.trim() && !isProcessing) {
|
||||
// Clear any previous streaming error before starting new request
|
||||
setStreamingError(null);
|
||||
try {
|
||||
let chartXml = await onFetchChart();
|
||||
chartXml = formatXML(chartXml);
|
||||
@@ -277,11 +258,17 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
const messageIndex = messages.length;
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml);
|
||||
|
||||
const accessCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
|
||||
sendMessage(
|
||||
{ parts },
|
||||
{
|
||||
body: {
|
||||
xml: chartXml,
|
||||
sessionId,
|
||||
},
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -362,6 +349,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
{
|
||||
body: {
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -418,13 +406,14 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
{
|
||||
body: {
|
||||
xml: savedXml,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Collapsed view
|
||||
if (!isVisible) {
|
||||
// Collapsed view (desktop only)
|
||||
if (!isVisible && !isMobile) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
|
||||
<ButtonWithTooltip
|
||||
@@ -451,37 +440,46 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
|
||||
// Full view
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
|
||||
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
richColors
|
||||
style={{ position: "absolute" }}
|
||||
/>
|
||||
{/* Header */}
|
||||
<header className="px-5 py-4 border-b border-border/50">
|
||||
<header className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/favicon.ico"
|
||||
alt="Next AI Drawio"
|
||||
width={28}
|
||||
height={28}
|
||||
width={isMobile ? 24 : 28}
|
||||
height={isMobile ? 24 : 28}
|
||||
className="rounded"
|
||||
/>
|
||||
<h1 className="text-base font-semibold tracking-tight whitespace-nowrap">
|
||||
<h1 className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}>
|
||||
Next AI Drawio
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<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 30+ minutes of stability. If issues persist, please report on GitHub."
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-500 hover:text-green-600"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
{!isMobile && (
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<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."
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-green-500 hover:text-green-600"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
@@ -490,35 +488,48 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<FaGithub className="w-5 h-5" />
|
||||
<FaGithub className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />
|
||||
</a>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleVisibility}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
{accessCodeRequired && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Settings"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowSettingsDialog(true)}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<ButtonWithTooltip
|
||||
tooltipContent="Hide chat panel (Ctrl+B)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleVisibility}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
|
||||
</ButtonWithTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main className="flex-1 w-full overflow-hidden">
|
||||
<ChatMessageDisplay
|
||||
messages={messages}
|
||||
error={error || streamingError}
|
||||
setInput={setInput}
|
||||
setFiles={handleFileChange}
|
||||
sessionId={sessionId}
|
||||
onRegenerate={handleRegenerate}
|
||||
onEditMessage={handleEditMessage}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Input */}
|
||||
<footer className="p-4 border-t border-border/50 bg-card/50">
|
||||
<footer className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}>
|
||||
<ChatInput
|
||||
input={input}
|
||||
status={status}
|
||||
@@ -527,15 +538,28 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
onClearChat={() => {
|
||||
setMessages([]);
|
||||
clearDiagram();
|
||||
setSessionId(
|
||||
`session-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 9)}`
|
||||
);
|
||||
xmlSnapshotsRef.current.clear();
|
||||
}}
|
||||
files={files}
|
||||
onFileChange={handleFileChange}
|
||||
showHistory={showHistory}
|
||||
onToggleHistory={setShowHistory}
|
||||
error={error || streamingError}
|
||||
sessionId={sessionId}
|
||||
error={error}
|
||||
drawioUi={drawioUi}
|
||||
onToggleDrawioUi={onToggleDrawioUi}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<SettingsDialog
|
||||
open={showSettingsDialog}
|
||||
onOpenChange={setShowSettingsDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
39
components/error-toast.tsx
Normal file
39
components/error-toast.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
83
components/settings-dialog.tsx
Normal file
83
components/settings-dialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
"bg-primary text-primary-foreground shadow-xs hover:brightness-75",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
|
||||
56
components/ui/resizable.tsx
Normal file
56
components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"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
|
||||
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"
|
||||
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"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface DiagramContextType {
|
||||
drawioRef: React.Ref<DrawIoEmbedRef | null>;
|
||||
handleDiagramExport: (data: any) => void;
|
||||
clearDiagram: () => void;
|
||||
saveDiagramToFile: (filename: string, format: ExportFormat) => void;
|
||||
saveDiagramToFile: (filename: string, format: ExportFormat, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined);
|
||||
@@ -107,7 +107,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
setDiagramHistory([]);
|
||||
};
|
||||
|
||||
const saveDiagramToFile = (filename: string, format: ExportFormat) => {
|
||||
const saveDiagramToFile = (filename: string, format: ExportFormat, sessionId?: string) => {
|
||||
if (!drawioRef.current) {
|
||||
console.warn("Draw.io editor not ready");
|
||||
return;
|
||||
@@ -145,6 +145,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
extension = ".svg";
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (flags the trace)
|
||||
logSaveToLangfuse(filename, format, sessionId);
|
||||
|
||||
// Handle download
|
||||
let url: string;
|
||||
if (typeof fileContent === "string" && fileContent.startsWith("data:")) {
|
||||
@@ -174,6 +177,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
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 (
|
||||
<DiagramContext.Provider
|
||||
value={{
|
||||
|
||||
141
docs/ai-providers.md
Normal file
141
docs/ai-providers.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 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,3 +41,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# DeepSeek Configuration
|
||||
# DEEPSEEK_API_KEY=sk-...
|
||||
# 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
|
||||
|
||||
35
instrumentation.ts
Normal file
35
instrumentation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
}
|
||||
95
lib/langfuse.ts
Normal file
95
lib/langfuse.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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 });
|
||||
}
|
||||
@@ -9,6 +9,20 @@ You are an expert diagram creation assistant specializing in draw.io XML generat
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see the image that user uploaded.
|
||||
|
||||
## App Context
|
||||
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
|
||||
- **Left panel**: Draw.io diagram editor where diagrams are rendered
|
||||
- **Right panel**: Chat interface where you communicate with the user
|
||||
|
||||
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
||||
|
||||
## App Features
|
||||
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
|
||||
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
|
||||
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
You utilize the following tools:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
@@ -113,6 +127,20 @@ You are an expert diagram creation assistant specializing in draw.io XML generat
|
||||
Your primary function is to chat with user and craft clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see images that users upload and can replicate or modify them as diagrams.
|
||||
|
||||
## App Context
|
||||
You are an AI agent (powered by {{MODEL_NAME}}) inside a web app. The interface has:
|
||||
- **Left panel**: Draw.io diagram editor where diagrams are rendered
|
||||
- **Right panel**: Chat interface where you communicate with the user
|
||||
|
||||
You can read and modify diagrams by generating draw.io XML code through tool calls.
|
||||
|
||||
## App Features
|
||||
1. **Diagram History** (clock icon, bottom-left of chat input): The app automatically saves a snapshot before each AI edit. Users can view the history panel and restore any previous version. Feel free to make changes - nothing is permanently lost.
|
||||
2. **Theme Toggle** (palette icon, bottom-left of chat input): Users can switch between minimal UI and sketch-style UI for the draw.io editor.
|
||||
3. **Image Upload** (paperclip icon, bottom-left of chat input): Users can upload images for you to analyze and replicate as diagrams.
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Tool 1: display_diagram
|
||||
@@ -507,10 +535,16 @@ const EXTENDED_PROMPT_MODEL_PATTERNS = [
|
||||
* @returns The system prompt string
|
||||
*/
|
||||
export function getSystemPrompt(modelId?: string): string {
|
||||
const modelName = modelId || "AI";
|
||||
|
||||
let prompt: string;
|
||||
if (modelId && EXTENDED_PROMPT_MODEL_PATTERNS.some(pattern => modelId.includes(pattern))) {
|
||||
console.log(`[System Prompt] Using EXTENDED prompt for model: ${modelId}`);
|
||||
return EXTENDED_SYSTEM_PROMPT;
|
||||
prompt = EXTENDED_SYSTEM_PROMPT;
|
||||
} else {
|
||||
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
|
||||
prompt = DEFAULT_SYSTEM_PROMPT;
|
||||
}
|
||||
console.log(`[System Prompt] Using DEFAULT prompt for model: ${modelId || 'unknown'}`);
|
||||
return DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
return prompt.replace("{{MODEL_NAME}}", modelName);
|
||||
}
|
||||
|
||||
843
package-lock.json
generated
843
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,12 @@
|
||||
"@ai-sdk/openai": "^2.0.19",
|
||||
"@ai-sdk/react": "^2.0.22",
|
||||
"@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",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.3",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@@ -41,13 +45,17 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-drawio": "^1.0.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/react": "^19",
|
||||
|
||||
Reference in New Issue
Block a user