feat(project): 项目完结
This commit is contained in:
66
frontend/src/api/agent.ts
Normal file
66
frontend/src/api/agent.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
96
frontend/src/app/globals.css
Normal file
96
frontend/src/app/globals.css
Normal 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 */
|
||||
}
|
||||
26
frontend/src/app/layout.tsx
Normal file
26
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/app/login/page.tsx
Normal file
178
frontend/src/app/login/page.tsx
Normal 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
998
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/config/api-config.ts
Normal file
6
frontend/src/config/api-config.ts
Normal 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
31
frontend/src/types/api.ts
Normal 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
9
frontend/src/types/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__ENV?: {
|
||||
NEXT_PUBLIC_API_BASE_URL: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
46
frontend/src/utils/cookie.ts
Normal file
46
frontend/src/utils/cookie.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user