mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
clear button cant clear error msg & feat: add setting dialog and add accesscode (#77)
* fix: clear button cant clear error msg * new: add setting dialog and add accesscode * fix: address review feedback - dark mode, types, formatting * feat: only show Settings button when access code is required * refactor: rename ACCESS_CODES to ACCESS_CODE_LIST --------- Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
@@ -149,6 +149,9 @@ 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 [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
See the [Provider Configuration Guide](./docs/ai-providers.md) for detailed setup instructions for each provider.
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ 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)。
|
详细设置说明请参阅[提供商配置指南](./docs/ai-providers.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ 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)を参照してください。
|
詳細な設定手順については[プロバイダー設定ガイド](./docs/ai-providers.md)を参照してください。
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ 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 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();
|
const { messages, xml, sessionId } = await req.json();
|
||||||
|
|
||||||
// Get user IP for Langfuse tracking
|
// Get user IP for Langfuse tracking
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -64,7 +64,6 @@ 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;
|
sessionId?: string;
|
||||||
@@ -74,7 +73,6 @@ interface ChatMessageDisplayProps {
|
|||||||
|
|
||||||
export function ChatMessageDisplay({
|
export function ChatMessageDisplay({
|
||||||
messages,
|
messages,
|
||||||
error,
|
|
||||||
setInput,
|
setInput,
|
||||||
setFiles,
|
setFiles,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -391,6 +389,8 @@ 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={() => {
|
||||||
@@ -501,11 +501,6 @@ 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,7 +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 { PanelRightClose, PanelRightOpen } from "lucide-react";
|
import { PanelRightClose, PanelRightOpen, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ 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 { Toaster } from "sonner";
|
||||||
|
import { SettingsDialog, STORAGE_ACCESS_CODE_KEY } from "@/components/settings-dialog";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -61,8 +62,18 @@ 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("");
|
||||||
|
|
||||||
|
// 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
|
// Generate a unique session ID for Langfuse tracing
|
||||||
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
|
const [sessionId, setSessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`);
|
||||||
|
|
||||||
@@ -168,7 +179,27 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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);
|
console.error("Chat error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.message.includes("Invalid or missing access code")) {
|
||||||
|
// Show settings button and open dialog to help user fix it
|
||||||
|
setAccessCodeRequired(true);
|
||||||
|
setShowSettingsDialog(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,6 +246,7 @@ 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 },
|
||||||
{
|
{
|
||||||
@@ -222,6 +254,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
xml: chartXml,
|
xml: chartXml,
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
"x-access-code": accessCode,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -426,6 +461,17 @@ 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"
|
||||||
@@ -443,7 +489,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<ChatMessageDisplay
|
<ChatMessageDisplay
|
||||||
messages={messages}
|
messages={messages}
|
||||||
error={error}
|
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
setFiles={handleFileChange}
|
setFiles={handleFileChange}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
@@ -473,6 +518,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
|||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<SettingsDialog
|
||||||
|
open={showSettingsDialog}
|
||||||
|
onOpenChange={setShowSettingsDialog}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,3 +47,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# LANGFUSE_PUBLIC_KEY=pk-lf-...
|
# LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||||
# LANGFUSE_SECRET_KEY=sk-lf-...
|
# LANGFUSE_SECRET_KEY=sk-lf-...
|
||||||
# LANGFUSE_BASEURL=https://cloud.langfuse.com # EU region, use https://us.cloud.langfuse.com for US
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user