feat(project): 项目完结

This commit is contained in:
peakxy
2026-05-30 23:16:49 +08:00
commit 60e7777cbd
97 changed files with 15027 additions and 0 deletions

66
frontend/src/api/agent.ts Normal file
View File

@@ -0,0 +1,66 @@
import { API_CONFIG } from '@/config/api-config';
import {
Response,
AiAgentConfigResponseDTO,
CreateSessionResponseDTO,
ChatRequestDTO,
ChatResponseDTO
} from '@/types/api';
const handleResponse = async <T>(response: globalThis.Response): Promise<Response<T>> => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
const data = await response.json();
if (data.code !== "0000") {
throw new Error(data.info || 'Unknown API error');
}
return data;
};
export const agentApi = {
/**
* Query AI Agent Config List
* Path: /api/v1/query_ai_agent_config_list
*/
queryAiAgentConfigList: async (): Promise<Response<AiAgentConfigResponseDTO[]>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/query_ai_agent_config_list`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return handleResponse<AiAgentConfigResponseDTO[]>(response);
},
/**
* Create Session
* Path: /api/v1/create_session
*/
createSession: async (agentId: string, userId: string): Promise<Response<CreateSessionResponseDTO>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/create_session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ agentId, userId }),
});
return handleResponse<CreateSessionResponseDTO>(response);
},
/**
* Chat
* Path: /api/v1/chat
*/
chat: async (data: ChatRequestDTO): Promise<Response<ChatResponseDTO>> => {
const response = await fetch(`${API_CONFIG.BASE_URL}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
return handleResponse<ChatResponseDTO>(response);
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,96 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
/* Custom Theme Variables from login.html/index.html */
--bg0: #070a12;
--bg1: #0b1022;
--card: rgba(255, 255, 255, 0.06);
--card2: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.72);
--muted2: rgba(255, 255, 255, 0.56);
--border: rgba(255, 255, 255, 0.12);
--primary: #62f6c7;
--primary2: #5aa9ff;
--danger: #ff5a7a;
--shadow: 0 22px 60px rgba(0, 0, 0, 0.55);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Utility classes for the custom theme */
.theme-bg-gradient {
background: radial-gradient(1200px 600px at 20% 18%, rgba(98, 246, 199, 0.24), rgba(98, 246, 199, 0) 55%),
radial-gradient(900px 560px at 78% 20%, rgba(90, 169, 255, 0.22), rgba(90, 169, 255, 0) 55%),
radial-gradient(900px 640px at 55% 78%, rgba(255, 90, 122, 0.12), rgba(255, 90, 122, 0) 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
}
.theme-card {
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
box-shadow: var(--shadow);
}
.theme-input {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.18);
color: var(--text);
}
.theme-input:focus {
border-color: rgba(98, 246, 199, 0.55);
box-shadow: 0 0 0 4px rgba(98, 246, 199, 0.12);
}
.theme-btn {
background: linear-gradient(135deg, var(--primary), var(--primary2));
color: rgba(7, 10, 18, 0.92);
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35);
}
.theme-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.14);
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1; /* slate-300 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8; /* slate-400 */
}

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Agent-Draw-IO",
description: "智能体交互绘图 @小傅哥",
};
import Script from "next/script";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<Script src="/env-config.js" strategy="beforeInteractive" />
</head>
<body className="antialiased">
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { setUserInfo, getUserInfo, clearUserInfo } from '@/utils/cookie';
export default function Login() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [msg, setMsg] = useState({ text: '', type: '' });
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [currentUser, setCurrentUser] = useState('');
useEffect(() => {
const userInfo = getUserInfo();
if (userInfo && userInfo.user) {
setIsLoggedIn(true);
setCurrentUser(userInfo.user);
// If already logged in, redirect to home
router.push('/');
}
}, [router]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setMsg({ text: '', type: '' });
if (!username || !password) {
setMsg({ text: '请输入账号与密码。', type: 'error' });
return;
}
if (username !== 'admin' || password !== 'admin') {
setMsg({ text: '账号或密码错误演示账号admin / admin。', type: 'error' });
return;
}
setUserInfo(username);
setMsg({ text: '登录成功,正在跳转…', type: 'info' });
setTimeout(() => {
router.push('/');
}, 500);
};
const handleFillDemo = () => {
setUsername('admin');
setPassword('admin');
setMsg({ text: '已填充演示账号。', type: 'info' });
};
const handleLogout = () => {
clearUserInfo();
setIsLoggedIn(false);
setCurrentUser('');
setMsg({ text: '已退出登录cookie 已清除。', type: 'info' });
};
return (
<div className="min-h-screen flex justify-center items-stretch p-7 theme-bg-gradient">
<div className="w-full max-w-[1120px] grid grid-cols-1 lg:grid-cols-[1.25fr_0.75fr] gap-[18px]">
{/* Hero Section */}
<section className="theme-card rounded-[18px] overflow-hidden relative flex flex-col gap-[18px] p-[28px_28px_22px_28px]">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-[14px] grid place-items-center bg-gradient-to-br from-[#62f6c7] to-[#5aa9ff] shadow-[0_10px_24px_rgba(0,0,0,0.4)] text-[rgba(7,10,18,0.92)] font-extrabold text-lg tracking-[0.5px]">
AI
</div>
<div className="flex flex-col gap-1">
<strong className="text-base leading-[1.1] tracking-[0.2px] text-[rgba(255,255,255,0.92)]">
AI By Ai Agent Scaffold - @小傅哥
</strong>
<span className="text-xs text-[rgba(255,255,255,0.56)]"> · · </span>
</div>
</div>
<h1 className="mt-[6px] text-[30px] leading-[1.2] tracking-[0.2px] text-[rgba(255,255,255,0.92)] font-bold">
</h1>
<p className="m-0 text-[rgba(255,255,255,0.72)] leading-[1.7] max-w-[52ch] text-sm">
<b>admin</b> <b>admin</b> cookie
</p>
<div className="grid grid-cols-2 gap-3 mt-[6px]">
{[
{ title: '工具调用', desc: '支持 API / Shell / 文件等执行链路编排' },
{ title: '记忆与上下文', desc: '可配置可审计,减少重复沟通成本' },
{ title: '多模型路由', desc: '按场景选择最合适的模型与策略' },
{ title: '可观测性', desc: '链路、成本、失败原因都能追踪' },
].map((item, idx) => (
<div key={idx} className="border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] rounded-[14px] p-3 flex gap-[10px] items-start">
<div className="w-[10px] h-[10px] rounded-full mt-[5px] flex-shrink-0 bg-gradient-to-br from-[#62f6c7] to-[#5aa9ff] shadow-[0_0_0_4px_rgba(98,246,199,0.08)]"></div>
<div>
<b className="block text-[13px] mb-[3px] text-[rgba(255,255,255,0.92)]">{item.title}</b>
<span className="block text-xs text-[rgba(255,255,255,0.56)] leading-[1.5]">{item.desc}</span>
</div>
</div>
))}
</div>
<div className="mt-[10px] rounded-[16px] overflow-hidden border border-[rgba(255,255,255,0.10)] bg-[rgba(0,0,0,0.24)] h-[340px] relative">
{/* Placeholder for Hero Image - mimicking the original svg placeholder */}
<div className="w-full h-full flex items-center justify-center text-[rgba(255,255,255,0.2)] text-sm">
AI
</div>
</div>
</section>
{/* Login Form Section */}
<section className="p-[28px] flex flex-col justify-center gap-[14px]">
<div className="theme-card rounded-[16px] p-5">
<h2 className="m-0 mb-[6px] text-[18px] text-[rgba(255,255,255,0.92)] font-bold"></h2>
<p className="m-0 mb-4 text-[rgba(255,255,255,0.56)] text-xs leading-[1.5]">
admin / admin
</p>
{!isLoggedIn ? (
<form onSubmit={handleLogin} autoComplete="on">
<div className="flex flex-col gap-2 mb-3">
<label htmlFor="username" className="text-xs text-[rgba(255,255,255,0.72)] tracking-[0.2px]"></label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入账号"
autoComplete="username"
className="w-full rounded-[12px] theme-input p-3 outline-none transition-all duration-180 text-sm"
/>
</div>
<div className="flex flex-col gap-2 mb-3">
<label htmlFor="password" className="text-xs text-[rgba(255,255,255,0.72)] tracking-[0.2px]"></label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
className="w-full rounded-[12px] theme-input p-3 outline-none transition-all duration-180 text-sm"
/>
</div>
<div className="flex gap-[10px] items-center justify-between mt-[6px]">
<button type="submit" className="theme-btn rounded-[12px] p-[11px_14px] font-bold cursor-pointer border-0 transition-transform active:translate-y-[1px] active:brightness-[0.98] text-sm">
Cookie
</button>
<button type="button" onClick={handleFillDemo} className="theme-btn-secondary rounded-[12px] p-[11px_14px] font-semibold cursor-pointer transition-transform active:translate-y-[1px] active:brightness-[0.98] text-sm">
</button>
</div>
</form>
) : (
<div className="flex gap-[10px] items-center justify-between p-3 border border-dashed border-[rgba(255,255,255,0.18)] rounded-[12px] bg-[rgba(255,255,255,0.04)] mt-3">
<div>
<strong className="block text-[13px] text-[rgba(255,255,255,0.92)]">{currentUser}</strong>
<span className="block text-xs text-[rgba(255,255,255,0.56)] mt-[2px]"></span>
</div>
<button onClick={handleLogout} className="theme-btn-secondary rounded-[12px] p-[8px_12px] font-semibold cursor-pointer text-xs">
退
</button>
</div>
)}
<div className={`min-h-[18px] text-xs mt-2 ${msg.type === 'error' ? 'text-[#ff5a7a]' : 'text-[rgba(255,255,255,0.56)]'}`}>
{msg.text}
</div>
</div>
<div className="mt-[14px] text-[rgba(255,255,255,0.35)] text-xs text-center">
© AI Agent Scaffold · Next.js
</div>
</section>
</div>
</div>
);
}

998
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,998 @@
'use client';
import { DrawIoEmbed, DrawIoEmbedRef } from 'react-drawio';
import { useRef, useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getUserInfo, clearUserInfo } from '@/utils/cookie';
import { agentApi } from '@/api/agent';
import { AiAgentConfigResponseDTO } from '@/types/api';
// Message type definition
type Message = {
id: string;
role: 'user' | 'agent';
content: string;
timestamp: number;
};
const isDrawIoXmlContent = (content: string) => {
const trimmed = content.trim();
return trimmed.startsWith('<mxfile') ||
trimmed.startsWith('<mxGraphModel') ||
trimmed.includes('<mxCell') ||
trimmed.includes('<diagram');
};
// Elegant SVG Icons with consistent styling
const Icons = {
Chat: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
),
Close: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
),
Send: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
),
User: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
),
Bot: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2 2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"></path>
<path d="M4 11v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2z"></path>
<path d="M9 22v-3"></path>
<path d="M15 22v-3"></path>
</svg>
),
Download: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
),
Sparkles: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
</svg>
),
Logout: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
),
Layers: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
),
Loader: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`animate-spin ${className}`}>
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
),
Plus: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
),
Trash: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
),
MessageSquare: ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
)
};
interface Session {
id: string;
backendSessionId?: string;
title: string;
messages: Message[];
drawIoXml: string | null;
lastModified: number;
}
export default function Home() {
const router = useRouter();
const [imgData, setImgData] = useState<string | null>(null);
const drawioRef = useRef<DrawIoEmbedRef>(null);
// User State
const [currentUser, setCurrentUser] = useState('');
// Chat State
const [isChatOpen, setIsChatOpen] = useState(true);
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
}
]);
const [inputValue, setInputValue] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Context State
const [useHistoryContext, setUseHistoryContext] = useState(false);
const [lastExportedData, setLastExportedData] = useState<{data: string, timestamp: number} | null>(null);
const isExportingForChatRef = useRef(false);
const isAutosaveRef = useRef(false);
const pendingMessageRef = useRef('');
const [isDrawIoReady, setIsDrawIoReady] = useState(false);
const initialLoadDoneRef = useRef(false);
// Agent State
const [agents, setAgents] = useState<AiAgentConfigResponseDTO[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState('');
const [sessionId, setSessionId] = useState('');
// Rename State
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [newSessionTitle, setNewSessionTitle] = useState('');
// Session Management State
const [sessions, setSessions] = useState<Session[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const currentSessionRef = useRef(currentSessionId);
// Update ref
useEffect(() => {
currentSessionRef.current = currentSessionId;
}, [currentSessionId]);
// Handle Initial Load
useEffect(() => {
if (!initialLoadDoneRef.current && isDrawIoReady && currentSessionId && sessions.length > 0) {
const session = sessions.find(s => s.id === currentSessionId);
if (session && session.drawIoXml && drawioRef.current) {
drawioRef.current.load({ xml: session.drawIoXml });
}
initialLoadDoneRef.current = true;
}
}, [isDrawIoReady, currentSessionId, sessions]);
// Load sessions from localStorage
useEffect(() => {
const savedSessions = localStorage.getItem('drawio_sessions');
if (savedSessions) {
try {
const parsed = JSON.parse(savedSessions);
setSessions(parsed);
if (parsed.length > 0) {
// Load the most recent session (first one if sorted by lastModified desc)
const mostRecent = parsed.sort((a: Session, b: Session) => b.lastModified - a.lastModified)[0];
setCurrentSessionId(mostRecent.id);
setMessages(mostRecent.messages);
// Note: Draw.io XML loading happens after drawioRef is ready or when we switch
} else {
createNewSession(true);
}
} catch (e) {
console.error('Failed to parse sessions:', e);
createNewSession(true);
}
} else {
createNewSession(true);
}
}, []);
// Save sessions to localStorage whenever they change
useEffect(() => {
if (sessions.length > 0) {
try {
localStorage.setItem('drawio_sessions', JSON.stringify(sessions));
} catch (e) {
console.error('Failed to save sessions to localStorage:', e);
}
}
}, [sessions]);
// Update current session messages and backendSessionId when they change
useEffect(() => {
if (currentSessionId) {
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
messages,
backendSessionId: sessionId,
// Update title if it's the default "New Chat" and we have a user message
title: session.title === 'New Chat' && messages.find(m => m.role === 'user')
? (messages.find(m => m.role === 'user')?.content.slice(0, 20) || 'New Chat')
: session.title
};
}
return session;
}));
}
}, [messages, currentSessionId, sessionId]);
const createNewSession = (isInitial = false, backendId = '') => {
const newSession: Session = {
id: Date.now().toString(),
backendSessionId: backendId,
title: 'New Chat',
messages: [{
id: Date.now().toString(),
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
}],
drawIoXml: null,
lastModified: Date.now()
};
setSessions(prev => [newSession, ...prev]);
setCurrentSessionId(newSession.id);
setMessages(newSession.messages);
setSessionId(backendId);
if (!isInitial && drawioRef.current) {
drawioRef.current.load({ xml: '' }); // Clear diagram
}
};
const handleSwitchSession = (targetSessionId: string) => {
if (targetSessionId === currentSessionId) return;
loadSession(targetSessionId);
};
const loadSession = (targetSessionId: string) => {
const session = sessions.find(s => s.id === targetSessionId);
if (session) {
setCurrentSessionId(targetSessionId);
setMessages(session.messages);
setSessionId(session.backendSessionId || '');
if (drawioRef.current && session.drawIoXml) {
drawioRef.current.load({ xml: session.drawIoXml });
} else if (drawioRef.current) {
drawioRef.current.load({ xml: '' });
}
}
};
const handleDeleteSession = (e: React.MouseEvent, sessionIdToDelete: string) => {
e.stopPropagation();
const newSessions = sessions.filter(s => s.id !== sessionIdToDelete);
setSessions(newSessions);
localStorage.setItem('drawio_sessions', JSON.stringify(newSessions));
if (currentSessionId === sessionIdToDelete) {
if (newSessions.length > 0) {
loadSession(newSessions[0].id);
} else {
createNewSession();
}
}
};
const handleDoubleClickSession = (session: Session) => {
setRenamingSessionId(session.id);
setNewSessionTitle(session.title);
setIsRenameModalOpen(true);
};
const handleRenameSave = () => {
if (renamingSessionId && newSessionTitle.trim()) {
setSessions(prev => prev.map(s =>
s.id === renamingSessionId ? { ...s, title: newSessionTitle.trim() } : s
));
setIsRenameModalOpen(false);
setRenamingSessionId(null);
setNewSessionTitle('');
}
};
const exportDiagram = () => {
if (drawioRef.current) {
drawioRef.current.exportDiagram({
format: 'xmlsvg'
});
}
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, isChatOpen]);
// Check Login & Load Agents
useEffect(() => {
const userInfo = getUserInfo();
if (!userInfo || !userInfo.user) {
router.push('/login');
return;
}
setCurrentUser(userInfo.user);
// Load Agents
const loadAgents = async () => {
try {
const res = await agentApi.queryAiAgentConfigList();
setAgents(res.data || []);
if (res.data && res.data.length > 0) {
// Try to restore last agent or default to first
const lastAgentId = localStorage.getItem('ai_agent_last_agent');
if (lastAgentId && res.data.find(a => a.agentId === lastAgentId)) {
setSelectedAgentId(lastAgentId);
} else {
setSelectedAgentId(res.data[0].agentId);
}
}
} catch (error) {
console.error('Failed to load agents:', error);
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'agent',
content: '加载智能体列表失败,请检查后端服务是否启动。',
timestamp: Date.now()
}]);
}
};
loadAgents();
}, [router]);
const handleLogout = () => {
clearUserInfo();
router.push('/login');
};
const handleAgentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newAgentId = e.target.value;
setSelectedAgentId(newAgentId);
setSessionId(''); // Reset session when agent changes
localStorage.setItem('ai_agent_last_agent', newAgentId);
};
const finalizeNewChat = async () => {
if (!selectedAgentId || !currentUser) return;
try {
const res = await agentApi.createSession(selectedAgentId, currentUser);
createNewSession(false, res.data.sessionId);
setInputValue('');
} catch (error) {
console.error('Failed to create new session:', error);
}
};
const handleNewChat = async () => {
finalizeNewChat();
};
const handleRestartSession = async () => {
if (!selectedAgentId || !currentUser) return;
if (!currentSessionId) {
finalizeNewChat();
return;
}
try {
const res = await agentApi.createSession(selectedAgentId, currentUser);
const newBackendId = res.data.sessionId;
const initialMsg: Message = {
id: Date.now().toString(),
role: 'agent',
content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
timestamp: Date.now()
};
setSessionId(newBackendId);
setMessages([initialMsg]);
setInputValue('');
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
backendSessionId: newBackendId,
messages: [initialMsg],
lastModified: Date.now()
};
}
return session;
}));
} catch (error) {
console.error('Failed to restart session:', error);
}
};
const performSendMessage = async (displayContent: string, apiContent: string) => {
if (!selectedAgentId) {
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'agent',
content: '请先选择一个智能体。',
timestamp: Date.now()
}]);
setIsSending(false);
return;
}
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: displayContent,
timestamp: Date.now()
};
setMessages(prev => [...prev, userMsg]);
try {
// 1. Ensure Session
let activeBackendSessionId = sessionId;
if (!activeBackendSessionId) {
const sessionRes = await agentApi.createSession(selectedAgentId, currentUser);
activeBackendSessionId = sessionRes.data.sessionId;
setSessionId(activeBackendSessionId);
}
// Update session lastModified
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return { ...session, lastModified: Date.now() };
}
return session;
}));
// 2. Send Message
const chatRes = await agentApi.chat({
agentId: selectedAgentId,
userId: currentUser,
sessionId: activeBackendSessionId,
message: apiContent
});
const { content } = chatRes.data;
// Go backend returns { content }; infer whether the content is draw.io XML.
if (!isDrawIoXmlContent(content)) {
const agentMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: content,
timestamp: Date.now()
};
setMessages(prev => [...prev, agentMsg]);
} else {
// Save to session immediately (always update the session that initiated the request)
setSessions(prev => prev.map(session => {
if (session.id === currentSessionId) {
return {
...session,
drawIoXml: content,
lastModified: Date.now()
};
}
return session;
}));
// Render only if still on the same session
if (drawioRef.current && currentSessionId === currentSessionRef.current) {
try {
drawioRef.current.load({
xml: content
});
} catch (e) {
console.error('Failed to load diagram:', e);
}
}
}
} catch (error) {
console.error('Chat error:', error);
const errorMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'agent',
content: error instanceof Error ? `Error: ${error.message}` : '发送失败,请重试。',
timestamp: Date.now()
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
};
const handleSendMessage = async () => {
if (!inputValue.trim() || isSending) return;
const content = inputValue;
setInputValue('');
setIsSending(true);
if (useHistoryContext && drawioRef.current && isDrawIoReady) {
isExportingForChatRef.current = true;
pendingMessageRef.current = content;
try {
drawioRef.current.exportDiagram({
format: 'xml' as any
});
} catch (e) {
console.error("Export failed", e);
performSendMessage(content, content);
}
} else {
performSendMessage(content, content);
}
};
useEffect(() => {
if (!lastExportedData) return;
if (isExportingForChatRef.current) {
isExportingForChatRef.current = false;
const xml = lastExportedData.data;
const content = pendingMessageRef.current;
const apiContent = `[Context: Current Draw.io XML]\n\`\`\`xml\n${xml}\n\`\`\`\n\n${content}`;
performSendMessage(content, apiContent);
return;
}
// Autosave handling
if (isAutosaveRef.current) {
isAutosaveRef.current = false;
const xml = lastExportedData.data;
setSessions(prev => prev.map(s => {
if (s.id === currentSessionId) {
return { ...s, drawIoXml: xml };
}
return s;
}));
return;
}
// Manual Export
setImgData(lastExportedData.data);
}, [lastExportedData]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSendMessage();
}
};
const quickActions = [
{ label: '绘制h5端登录流程图', text: '请帮我绘制一个H5端的登录流程图包含用户输入手机号、获取验证码、验证登录等步骤。' },
{ label: '绘制电商购物流程图', text: '请帮我绘制一个电商购物流程图,包含商品浏览、加入购物车、下单、支付、发货等环节。' }
];
return (
<div className="flex flex-col h-screen w-full overflow-hidden bg-slate-50 text-slate-900 font-sans">
{/* Header - Minimal & Clean */}
<div className="h-14 px-6 bg-white border-b border-slate-200 flex items-center justify-between shrink-0 z-40 relative">
<div className="flex items-center gap-3">
<div className="bg-indigo-600 p-1.5 rounded-lg shadow-sm shadow-indigo-200">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<h1 className="text-lg font-bold text-slate-800 tracking-tight">ai + draw.io <span className="text-slate-400 font-normal text-sm ml-2">@小傅哥</span></h1>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-full border border-slate-200 shadow-sm">
<div className="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]"></div>
<span className="text-xs font-semibold text-slate-600">{currentUser || 'Guest'}</span>
</div>
<div className="h-6 w-px bg-slate-200 mx-1"></div>
<button
onClick={exportDiagram}
className="flex items-center gap-2 px-4 py-1.5 bg-white border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900 transition-all text-sm font-medium shadow-sm active:scale-95"
>
<Icons.Download className="w-4 h-4" />
Export
</button>
<button
onClick={handleLogout}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Logout"
>
<Icons.Logout />
</button>
{!isChatOpen && (
<button
onClick={() => setIsChatOpen(true)}
className="p-2 text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors border border-indigo-100"
title="Open Assistant"
>
<Icons.Chat />
</button>
)}
</div>
</div>
{/* Main Layout */}
<div className="flex flex-1 w-full overflow-hidden relative">
{/* Sessions Sidebar */}
<div className="w-64 bg-white text-slate-600 flex flex-col border-r border-slate-200 shrink-0 z-30">
<div className="h-14 px-4 flex items-center justify-between border-b border-slate-100 shrink-0">
<span className="font-semibold text-slate-800 flex items-center gap-2">
<Icons.MessageSquare className="w-4 h-4 text-indigo-600" />
</span>
<button
onClick={handleNewChat}
className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all"
title="New Chat"
>
<Icons.Plus className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent">
{[...sessions].sort((a, b) => b.lastModified - a.lastModified).map(session => (
<div
key={session.id}
onClick={() => handleSwitchSession(session.id)}
onDoubleClick={(e) => { e.stopPropagation(); handleDoubleClickSession(session); }}
className={`
group flex items-center gap-3 px-3 py-3 rounded-lg cursor-pointer transition-all border border-transparent
${currentSessionId === session.id
? 'bg-indigo-50 text-indigo-700 border-indigo-100 shadow-sm'
: 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'
}
`}
>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${currentSessionId === session.id ? 'text-indigo-700' : 'text-slate-700 group-hover:text-slate-900'}`}>
{session.title}
</div>
<div className={`text-[10px] mt-0.5 ${currentSessionId === session.id ? 'text-indigo-400' : 'text-slate-400'}`}>
{new Date(session.lastModified).toLocaleDateString()} {new Date(session.lastModified).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(e, session.id)}
className={`
p-1.5 rounded-md transition-all opacity-0 group-hover:opacity-100
${currentSessionId === session.id
? 'hover:bg-indigo-100 text-indigo-400 hover:text-indigo-700'
: 'hover:bg-red-50 text-slate-400 hover:text-red-500'
}
`}
title="Delete"
>
<Icons.Trash className="w-4 h-4" />
</button>
</div>
))}
{sessions.length === 0 && (
<div className="text-center py-10 text-xs text-slate-400">
No history yet
</div>
)}
</div>
</div>
{/* Draw.io Canvas Area */}
<div className="flex-1 relative bg-slate-50 h-full flex flex-col">
<div className="flex-1 m-3 rounded-2xl overflow-hidden border border-slate-200 shadow-sm bg-white ring-1 ring-slate-100">
<DrawIoEmbed
ref={drawioRef}
autosave={true}
onAutoSave={(data) => {
if (currentSessionId && isDrawIoReady && !isExportingForChatRef.current) {
// Prefer using the XML directly from the autosave event if available
if (data && typeof data === 'object' && 'xml' in data) {
const xmlContent = (data as any).xml;
setSessions(prev => prev.map(s => {
if (s.id === currentSessionId) {
return { ...s, drawIoXml: xmlContent };
}
return s;
}));
} else {
// Fallback to export if no XML provided in event
isAutosaveRef.current = true;
drawioRef.current?.exportDiagram({ format: 'xml' as any });
}
}
}}
onLoad={() => setIsDrawIoReady(true)}
onExport={(data) => setLastExportedData({ data: data.data, timestamp: Date.now() })}
urlParameters={{
ui: 'atlas', // More modern UI theme for draw.io
spin: true,
libraries: true,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true
}}
/>
</div>
</div>
{/* Chat Sidebar - Modern & Elegant */}
<div
className={`
border-l border-slate-200 bg-white flex flex-col transition-all duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)]
${isChatOpen ? 'w-[380px] translate-x-0' : 'w-0 translate-x-full opacity-0 overflow-hidden'}
shadow-xl z-20
`}
>
{/* Chat Header */}
<div className="h-14 px-5 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white/80 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-md shadow-indigo-200 shrink-0 ring-2 ring-white">
<Icons.Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<select
value={selectedAgentId}
onChange={handleAgentChange}
className="w-full bg-transparent text-sm font-bold text-slate-800 focus:outline-none cursor-pointer truncate appearance-none pr-4"
style={{ backgroundImage: 'none' }}
>
{agents.length === 0 && <option value="">Loading agents...</option>}
{agents.map(agent => (
<option key={agent.agentId} value={agent.agentId}>
{agent.agentName}
</option>
))}
</select>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
<span className="text-[10px] text-slate-500 font-medium leading-tight">AI Assistant Online</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsChatOpen(false)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-all shrink-0"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`
shrink-0 w-8 h-8 rounded-full flex items-center justify-center shadow-sm mt-1 ring-2 ring-white
${msg.role === 'user'
? 'bg-indigo-100 text-indigo-600'
: 'bg-white text-indigo-500 border border-slate-100'
}
`}>
{msg.role === 'user' ? <Icons.User className="w-5 h-5" /> : <Icons.Bot className="w-5 h-5" />}
</div>
<div className="flex flex-col max-w-[85%]">
<span className={`text-[10px] mb-1.5 font-medium ${msg.role === 'user' ? 'text-right text-slate-400' : 'text-left text-slate-400'}`}>
{msg.role === 'user' ? 'You' : 'Agent'}
</span>
<div
className={`
p-3.5 text-sm leading-relaxed shadow-sm whitespace-pre-wrap
${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-2xl rounded-tr-sm shadow-indigo-200'
: 'bg-white border border-slate-200 text-slate-700 rounded-2xl rounded-tl-sm shadow-sm'
}
`}
>
{msg.content}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-white border-t border-slate-100 shrink-0 relative z-20 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.02)]">
{/* Quick Actions - Only show when chat is empty (just greeting) */}
{messages.length <= 1 && (
<div className="flex flex-wrap gap-2 mb-3 px-1 animate-in fade-in slide-in-from-bottom-2 duration-300">
{quickActions.map((action, idx) => (
<button
key={idx}
onClick={() => setInputValue(action.text)}
className="text-xs px-3 py-1.5 bg-indigo-50 text-indigo-600 rounded-full hover:bg-indigo-100 transition-colors border border-indigo-100 font-medium shadow-sm"
>
{action.label}
</button>
))}
</div>
)}
{/* Context Toolbar */}
<div className="flex items-center gap-2 mb-2 px-1">
<button
onClick={() => setUseHistoryContext(!useHistoryContext)}
className={`
flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition-all border shadow-sm
${useHistoryContext
? 'bg-indigo-50 text-indigo-600 border-indigo-200 ring-1 ring-indigo-100'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50 hover:text-slate-700'
}
`}
>
<Icons.Layers className={`w-3.5 h-3.5 ${useHistoryContext ? 'text-indigo-500' : 'text-slate-400'}`} />
<span></span>
</button>
<span className="text-[10px] text-slate-400 ml-auto hidden sm:inline-block">
Press <kbd className="font-sans px-1 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-500">Ctrl/Command</kbd> + <kbd className="font-sans px-1 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-500">Enter</kbd>
</span>
</div>
<div className="relative flex items-end gap-2 bg-slate-50 p-1.5 rounded-xl border border-slate-200 focus-within:border-indigo-300 focus-within:ring-4 focus-within:ring-indigo-50/50 focus-within:bg-white transition-all shadow-inner">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isSending ? "AI 正在思考中..." : "输入您的问题,描述您的需求..."}
disabled={isSending}
className="flex-1 px-3 py-2 bg-transparent border-none focus:ring-0 text-sm text-slate-800 placeholder:text-slate-400 resize-none max-h-60 min-h-[50px] scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent"
rows={1}
style={{ height: 'auto', minHeight: '50px' }}
/>
<div className="flex gap-1 mb-0.5 shrink-0">
<button
onClick={handleSendMessage}
disabled={!inputValue.trim() || isSending}
className={`
p-2.5 rounded-lg transition-all duration-200 flex items-center justify-center
${inputValue.trim() && !isSending
? 'bg-indigo-600 text-white shadow-md shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 active:scale-95'
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
}
`}
>
{isSending ? <Icons.Loader className="w-4 h-4" /> : <Icons.Send className="w-4 h-4" />}
</button>
<button
onClick={handleRestartSession}
className="p-2.5 rounded-lg bg-white text-slate-400 hover:bg-slate-50 hover:text-indigo-600 transition-all duration-200 border border-slate-200 hover:border-indigo-100 shadow-sm"
title="Restart Session"
>
<Icons.Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-center mt-2.5">
<p className="text-[10px] text-slate-400 font-medium">
{isSending ? 'AI is generating response...' : 'AI can make mistakes. Please verify important info.'}
</p>
</div>
</div>
</div>
</div>
{/* Export Modal - Polished */}
{imgData && (
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 p-6 animate-in fade-in duration-200">
<div className="bg-white p-0 rounded-2xl shadow-2xl max-h-[90vh] flex flex-col w-full max-w-4xl overflow-hidden animate-in zoom-in-95 duration-200 border border-white/20">
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 text-green-600 rounded-lg">
<Icons.Download className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-800">Export Ready</h2>
<p className="text-xs text-slate-500">Your diagram has been successfully converted</p>
</div>
</div>
<button
onClick={() => setImgData(null)}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-auto bg-slate-50/50 p-8 flex items-center justify-center min-h-[400px]">
<div className="bg-white p-2 rounded shadow-sm border border-slate-200">
<img src={imgData} alt="Exported diagram" className="max-w-full h-auto object-contain" />
</div>
</div>
<div className="px-6 py-4 border-t border-slate-100 bg-white flex justify-end gap-3">
<button
onClick={() => setImgData(null)}
className="px-5 py-2.5 text-slate-600 font-medium hover:bg-slate-100 rounded-lg transition-colors text-sm"
>
Close Preview
</button>
<a
href={imgData}
download="diagram.svg"
className="px-5 py-2.5 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 shadow-lg shadow-indigo-200 hover:shadow-indigo-300 transition-all text-sm flex items-center gap-2"
>
<Icons.Download className="w-4 h-4" />
Download File
</a>
</div>
</div>
</div>
)}
{/* Rename Modal */}
{isRenameModalOpen && (
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 p-6 animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200 border border-white/20">
<div className="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-800">Rename Session</h2>
<button
onClick={() => setIsRenameModalOpen(false)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<Icons.Close className="w-5 h-5" />
</button>
</div>
<div className="p-6">
<label className="block text-sm font-medium text-slate-700 mb-2">
Session Name
</label>
<input
type="text"
value={newSessionTitle}
onChange={(e) => setNewSessionTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRenameSave()}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="Enter new name"
autoFocus
/>
</div>
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50/50 flex justify-end gap-3">
<button
onClick={() => setIsRenameModalOpen(false)}
className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-100 rounded-lg transition-colors text-sm"
>
Cancel
</button>
<button
onClick={handleRenameSave}
className="px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 shadow-lg shadow-indigo-200 hover:shadow-indigo-300 transition-all text-sm"
>
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export const API_CONFIG = {
// Use environment variable from window.__ENV (runtime) or process.env (build/server)
BASE_URL: (typeof window !== 'undefined' && window.__ENV?.NEXT_PUBLIC_API_BASE_URL)
? window.__ENV.NEXT_PUBLIC_API_BASE_URL
: (process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8091/api/v1'),
};

31
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface Response<T> {
code: string;
info: string;
data: T;
}
export interface AiAgentConfigResponseDTO {
agentId: string;
agentName: string;
agentDesc: string;
}
export interface CreateSessionRequestDTO {
agentId: string;
userId: string;
}
export interface CreateSessionResponseDTO {
sessionId: string;
}
export interface ChatRequestDTO {
agentId: string;
userId: string;
sessionId: string;
message: string;
}
export interface ChatResponseDTO {
content: string;
}

9
frontend/src/types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare global {
interface Window {
__ENV?: {
NEXT_PUBLIC_API_BASE_URL: string;
};
}
}
export {};

View File

@@ -0,0 +1,46 @@
export const COOKIE_NAME = "ai_agent_login";
export const COOKIE_DAYS = 7;
export interface UserInfo {
user: string;
ts: number;
}
export const setCookie = (name: string, value: string, days: number) => {
const maxAge = Math.max(0, Math.floor(days * 86400));
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax`;
};
export const getCookie = (name: string): string | null => {
const cookies = document.cookie ? document.cookie.split("; ") : [];
for (const item of cookies) {
const eqIndex = item.indexOf("=");
const k = eqIndex >= 0 ? item.slice(0, eqIndex) : item;
const v = eqIndex >= 0 ? item.slice(eqIndex + 1) : "";
if (k === name) return decodeURIComponent(v);
}
return null;
};
export const deleteCookie = (name: string) => {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
};
export const getUserInfo = (): UserInfo | null => {
const raw = getCookie(COOKIE_NAME);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
export const setUserInfo = (user: string) => {
const payload: UserInfo = { user, ts: Date.now() };
setCookie(COOKIE_NAME, JSON.stringify(payload), COOKIE_DAYS);
};
export const clearUserInfo = () => {
deleteCookie(COOKIE_NAME);
};