feat: admin back-office system + AI assistant with action commands
- Admin analytics (recharts charts, overview stats, trend analysis, time range selector) - Admin permissions (AdminRole/AdminUser models, role CRUD, permission catalog) - Admin system config (site/AI/member category tabs, per-key save) - Admin operations (Banner CRUD, push notification send/delete) - Admin user management (table, search, create/edit, ban/delete) - Admin layout (custom top bar with branding + sidebar, admin AI assistant) - Admin login (adminToken localStorage, adminInfo display) - Admin AI assistant (purple '运营助手', context-aware prompts, action commands) - Public AI assistant Phase B (action commands: navigate, setModel, startChat, openSkill, setParameter) - Assistant context mapping + action executor - Skills API fix: tags parsing fallback (JSON.parse → split) - Sandbox crash fix: curScene fallback system prompt - JWT token expiration: access 2h→7d, refresh 7d→30d, admin 8h→7d - i18n: assistant-related translations (zh/en) - PM2 ecosystem config for process management - AI assistant login prompt fix: admin uses getAdminToken(), public uses apiFetch with token refresh
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { MessageCircle, X, Send, Minus, Sparkles, FileText, BookOpen, Image, Settings } from 'lucide-react';
|
||||
import { useT } from '@/i18n';
|
||||
import { getAdminToken } from '@/lib/auth';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface AdminContext {
|
||||
page: string;
|
||||
systemPrompt: string;
|
||||
starters: string[];
|
||||
}
|
||||
|
||||
const adminContexts: Record<string, AdminContext> = {
|
||||
'/admin': {
|
||||
page: 'dashboard',
|
||||
systemPrompt: '你是宇之然AI管理后台的智能助手。帮助管理员完成日常运营工作,包括:数据分析、用户管理、内容审核、订单处理等。',
|
||||
starters: ['查看今日数据', '最近有哪些新用户', '待处理订单数量', '系统运行状态'],
|
||||
},
|
||||
'/admin/users': {
|
||||
page: 'users',
|
||||
systemPrompt: '你是用户管理助手。可以帮助:查看用户列表、搜索用户、修改用户状态、查看用户详情。',
|
||||
starters: ['列出最近注册的用户', '查找某个用户', '批量启用/禁用用户', '查看用户详情'],
|
||||
},
|
||||
'/admin/courses': {
|
||||
page: 'courses',
|
||||
systemPrompt: '你是课程管理助手。可以帮助:创建新课程、编辑课程信息、上下架课程、管理课程章节。',
|
||||
starters: ['创建新课程', '课程列表', '下架某个课程', '添加课程章节'],
|
||||
},
|
||||
'/admin/contents': {
|
||||
page: 'contents',
|
||||
systemPrompt: '你是内容管理助手。可以帮助:创建文章、编辑内容、设置分类、发布/下架。',
|
||||
starters: ['创建新文章', '内容列表', '编辑某篇文章', '设置文章分类'],
|
||||
},
|
||||
'/admin/prompts': {
|
||||
page: 'prompts',
|
||||
systemPrompt: '你是提示词管理助手。可以帮助:创建提示词、审核提示词、设置分类、推荐优质提示词。',
|
||||
starters: ['创建新提示词', '待审核列表', '热门提示词', '添加提示词标签'],
|
||||
},
|
||||
'/admin/orders': {
|
||||
page: 'orders',
|
||||
systemPrompt: '你是订单管理助手。可以帮助:查看订单列表、订单详情、退款处理、收入统计。',
|
||||
starters: ['今日订单', '待处理订单', '收入统计', '订单详情'],
|
||||
},
|
||||
'/admin/analytics': {
|
||||
page: 'analytics',
|
||||
systemPrompt: '你是数据分析助手。可以帮助:解读数据指标、分析趋势、生成报表建议。',
|
||||
starters: ['用户增长趋势', '收入分析', '热门内容', '数据摘要'],
|
||||
},
|
||||
'/admin/operations': {
|
||||
page: 'operations',
|
||||
systemPrompt: '你是运营助手。可以帮助:创建Banner、发送推送通知、管理活动。',
|
||||
starters: ['创建Banner', '发送系统通知', '查看推送记录', '运营数据'],
|
||||
},
|
||||
'/admin/settings': {
|
||||
page: 'settings',
|
||||
systemPrompt: '你是系统设置助手。可以帮助:修改系统配置、查看配置项、批量设置。',
|
||||
starters: ['查看AI配置', '修改会员价格', '站点设置', '配置说明'],
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_FORMAT = `\n\n【快捷指令】当需要执行操作时,可以返回 JSON 指令:\n- {"action":"navigate","path":"/admin/courses","description":"跳转到课程管理"}\n- {"action":"search","keyword":"xxx","target":"users","description":"搜索用户"}\n- {"action":"create","type":"course","data":{"title":"课程名"},"description":"创建课程"}\n只有确实需要跳转或执行操作时才返回指令。`;
|
||||
|
||||
function getAdminContext(pathname: string): AdminContext {
|
||||
const sorted = Object.keys(adminContexts).sort((a, b) => b.length - a.length);
|
||||
for (const key of sorted) {
|
||||
if (pathname.startsWith(key)) {
|
||||
const ctx = { ...adminContexts[key] };
|
||||
ctx.systemPrompt = ctx.systemPrompt + ACTION_FORMAT;
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
const defaultCtx = { ...adminContexts['/admin'] };
|
||||
defaultCtx.systemPrompt = defaultCtx.systemPrompt + ACTION_FORMAT;
|
||||
return defaultCtx;
|
||||
}
|
||||
|
||||
function parseActionCommand(text: string): any | null {
|
||||
const jsonMatch = text.match(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/);
|
||||
if (!jsonMatch) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (parsed.action) return parsed;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AdminAIAssistant() {
|
||||
const t = useT();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !started) {
|
||||
const ctx = getAdminContext(pathname);
|
||||
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
|
||||
setStarted(true);
|
||||
}
|
||||
}, [open, pathname, started]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
async function handleSend(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
const userMsg: Message = { role: 'user', content: text };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const tk = getAdminToken();
|
||||
let reply = '';
|
||||
|
||||
if (tk) {
|
||||
const ctx = getAdminContext(pathname);
|
||||
const apiMessages = [
|
||||
{ role: 'system', content: ctx.systemPrompt },
|
||||
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: text },
|
||||
];
|
||||
|
||||
const res = await fetch(`${API_BASE}/sandbox/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${tk}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: crypto.randomUUID(),
|
||||
model: 'general',
|
||||
messages: apiMessages,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || '请求失败');
|
||||
reply = data.reply;
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
reply = '请先登录管理账号';
|
||||
}
|
||||
|
||||
const action = parseActionCommand(reply);
|
||||
const hasAction = !!action;
|
||||
|
||||
if (hasAction) {
|
||||
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
|
||||
const finalReply = cleanReply || '收到指令,正在处理...';
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: finalReply }]);
|
||||
|
||||
if (action.action === 'navigate' && action.path) {
|
||||
router.push(action.path);
|
||||
toast.success(`正在跳转:${action.description || action.path}`);
|
||||
} else {
|
||||
toast.info(action.description || '收到操作指令');
|
||||
}
|
||||
} else {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `出错:${e.message}` }]);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarter(starter: string) {
|
||||
setInput(starter);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-full shadow-lg hover:bg-purple-700 hover:shadow-xl hover:scale-105 transition-all"
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium">运营助手</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = getAdminContext(pathname);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<div
|
||||
className={`bg-card border border-border rounded-2xl shadow-2xl overflow-hidden transition-all duration-300 ${
|
||||
minimized ? 'h-14 w-72' : 'w-80 sm:w-96'
|
||||
}`}
|
||||
style={{ maxHeight: 'min(500px, 80vh)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-purple-500/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-purple-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">AI</div>
|
||||
<span className="text-sm font-semibold text-foreground">运营助手</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!minimized && (
|
||||
<>
|
||||
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '320px' }}>
|
||||
{messages.length === 1 && messages[0].role === 'assistant' && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
你好!我是运营助手,可以帮你完成后台管理工作。试试这些:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctx.starters.map((q, i) => (
|
||||
<button key={i} onClick={() => handleStarter(q)}
|
||||
className="text-xs px-2.5 py-1.5 bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground">
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-6 h-6 bg-purple-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">AI</div>
|
||||
)}
|
||||
<div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm ${
|
||||
msg.role === 'user' ? 'bg-purple-600 text-white rounded-tr-none' : 'bg-muted text-foreground rounded-tl-none'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">我</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sending && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 bg-purple-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0">AI</div>
|
||||
<div className="bg-muted rounded-2xl rounded-tl-none px-3 py-2">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" />
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<form onSubmit={handleSend} className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder="输入问题或指令..."
|
||||
disabled={sending}
|
||||
className="flex-1 px-3 py-2 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
/>
|
||||
<button type="submit" disabled={sending || !input.trim()}
|
||||
className="p-2 bg-purple-600 text-white rounded-xl hover:bg-purple-700 disabled:opacity-50">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { MessageCircle, X, Send, Minus, Sparkles } from 'lucide-react';
|
||||
import { getAssistantContext, parseActionCommand, type AssistantContext, type AssistantAction } from '@/lib/assistant-context';
|
||||
import { executeAction, setRouter } from '@/lib/assistant-actions';
|
||||
import { useT } from '@/i18n';
|
||||
import { getToken, apiFetch } from '@/lib/auth';
|
||||
import { DEFAULT_MODEL } from '@/lib/models';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
hasAction?: boolean;
|
||||
actionExecuted?: boolean;
|
||||
}
|
||||
|
||||
export function AIAssistant() {
|
||||
const t = useT();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [context, setContext] = useState<AssistantContext>(getAssistantContext(pathname));
|
||||
const [started, setStarted] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRouter(router);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
setContext(getAssistantContext(pathname));
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !started) {
|
||||
const ctx = getAssistantContext(pathname);
|
||||
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
|
||||
setStarted(true);
|
||||
}
|
||||
}, [open, pathname, started]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
async function handleSend(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
const userMsg: Message = { role: 'user', content: text };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const tk = getToken();
|
||||
let reply = '';
|
||||
if (tk) {
|
||||
const ctx = getAssistantContext(pathname);
|
||||
const apiMessages = [
|
||||
{ role: 'system', content: ctx.systemPrompt },
|
||||
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: text },
|
||||
];
|
||||
const res = await apiFetch('/sandbox/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
conversationId: crypto.randomUUID(),
|
||||
model: DEFAULT_MODEL,
|
||||
messages: apiMessages,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || '请求失败');
|
||||
reply = data.reply;
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
reply = '📝 登录后可体验完整 AI 对话功能。\n\n点击右上角「登录」或「注册」即可开始使用,解锁 AI 助手的全部能力。';
|
||||
}
|
||||
|
||||
const action = parseActionCommand(reply);
|
||||
const hasAction = !!action;
|
||||
|
||||
if (hasAction) {
|
||||
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
|
||||
const finalReply = cleanReply || '收到你的请求,正在处理...';
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: finalReply, hasAction: true }]);
|
||||
|
||||
const result = await executeAction(action);
|
||||
if (result.success) {
|
||||
toast.success(result.message, { icon: <Sparkles className="w-4 h-4" /> });
|
||||
setMessages(prev => prev.map((m, i) =>
|
||||
i === prev.length - 1 ? { ...m, actionExecuted: true } : m
|
||||
));
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} else {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `出错啦:${e.message}` }]);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarter(starter: string) {
|
||||
setInput(starter);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-3">
|
||||
<div className="relative group">
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-muted text-muted-foreground text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
{t.assistant?.title || 'AI 助手'}
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-muted rotate-45" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-brand-600 text-white rounded-full shadow-lg hover:bg-brand-700 hover:shadow-xl hover:scale-105 transition-all"
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-white"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium">{t.assistant?.title || 'AI 助手'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = getAssistantContext(pathname);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<div
|
||||
className={`bg-card border border-border rounded-2xl shadow-2xl overflow-hidden transition-all duration-300 ${
|
||||
minimized ? 'h-14 w-72' : 'w-80 sm:w-96'
|
||||
}`}
|
||||
style={{ maxHeight: 'min(600px, 80vh)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-brand-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">Y</div>
|
||||
<span className="text-sm font-semibold text-foreground">{t.assistant?.title || 'AI 助手'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!minimized && (
|
||||
<>
|
||||
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '360px' }}>
|
||||
{messages.length === 1 && messages[0].role === 'assistant' && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t.assistant?.greeting || '你好!我是宇之然 AI 助手,可以帮你了解和使用本站功能。试试下面的问题:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctx.starters.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleStarter(q)}
|
||||
className="text-xs px-2.5 py-1.5 bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => {
|
||||
const showActionIndicator = msg.hasAction && msg.actionExecuted && i === messages.length - 1;
|
||||
return (
|
||||
<div key={i} className={`flex items-start gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap relative ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-600 text-white rounded-tr-none'
|
||||
: 'bg-muted text-foreground rounded-tl-none'
|
||||
}`}
|
||||
>
|
||||
{i === messages.length - 1 && msg.role === 'assistant' && started && messages.length > 1
|
||||
? msg.content
|
||||
: msg.role === 'assistant' && i === 0
|
||||
? null
|
||||
: msg.content}
|
||||
{showActionIndicator && (
|
||||
<span className="absolute -top-2 -right-2 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-6 h-6 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5">我</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sending && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 bg-brand-600 rounded-lg flex items-center justify-center text-white text-[10px] font-bold shrink-0 mt-0.5">Y</div>
|
||||
<div className="bg-muted rounded-2xl rounded-tl-none px-3 py-2">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" />
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-muted-foreground/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<form onSubmit={handleSend} className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder={t.assistant?.placeholder || '输入你的问题...'}
|
||||
disabled={sending}
|
||||
className="flex-1 px-3 py-2 bg-background border border-input rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !input.trim()}
|
||||
className="p-2 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user