feat(web): 重构前端UI并支持OpenAI协议

- 添加账号管理详情页(基本信息、提示词、CapCut、参考图标签页)
- 重构资产页面,按项目组分开展示图片/视频
- 聊天界面支持深度思考内容折叠展示、复制、删除消息
- 设置页面支持Agent配置(Anthropic/OpenAI协议)和工具配置
- 后端支持OpenAI兼容协议流式输出和DeepSeek思考模式
- 添加对话置顶/删除功能、数据库迁移、资产清单API
- 添加账号参考图上传/删除、技能配置持久化、连接测试API
This commit is contained in:
2026-05-07 23:48:26 +08:00
parent 01963aac96
commit 088bdb9a8e
40 changed files with 2594 additions and 678 deletions

View File

@@ -1,69 +1,146 @@
import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
import { RefreshCw, ArrowRight, Quote, Bot, User } from 'lucide-react';
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Check, Brain } from 'lucide-react';
import type { Message } from '@/types';
type MessageState = 'streaming' | 'thinking' | 'done';
interface Props {
message: Message;
isLast?: boolean;
isThinking?: boolean;
onRegenerate?: (msgId: string) => void;
onContinue?: (msgId: string) => void;
onContinue?: () => void;
onQuote?: (content: string) => void;
onDelete?: (msgId: string) => void;
}
export function ChatMessage({ message, onRegenerate, onContinue, onQuote }: Props) {
function getState(msg: Message, isLast: boolean, isThinking: boolean): MessageState {
if (msg.role === 'user') return 'done';
if (isThinking && isLast) return 'thinking';
if (!msg.content && !msg.reasoningContent) return 'streaming';
return 'done';
}
export function ChatMessage({ message, isLast, isThinking, onRegenerate, onContinue, onQuote, onDelete }: Props) {
const isUser = message.role === 'user';
const isEmpty = !message.content;
const isTool = message.role === 'tool';
const [copied, setCopied] = useState(false);
const [reasoningOpen, setReasoningOpen] = useState(false);
const state = getState(message, !!isLast, !!isThinking);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [message.content]);
// Tool messages
if (isTool) {
let toolName = '';
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
return (
<div className="flex items-center gap-2 py-1 px-3">
<Bot size={13} className="text-zinc-400" />
<span className="text-xs text-zinc-400">: {toolName}</span>
</div>
);
}
const showActions = state === 'done' && !isUser && message.content;
return (
<div className={cn('flex gap-3 mb-6', isUser ? 'flex-row-reverse' : 'flex-row')}>
<div className={cn('flex gap-3 mb-5 group/msg', isUser ? 'flex-row-reverse' : 'flex-row')}>
{/* Avatar */}
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0',
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 text-xs',
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-100 text-zinc-500'
)}>
{isUser ? <User size={15} /> : <Bot size={15} />}
{isUser ? <User size={13} /> : <Bot size={13} />}
</div>
{/* Content */}
<div className={cn('flex-1 max-w-[75%]', isUser && 'flex flex-col items-end')}>
{/* Role label */}
<div className={cn('text-[10px] font-medium mb-1 px-1', isUser ? 'text-right text-indigo-500' : 'text-zinc-400')}>
{isUser ? '你' : '美图 Agent'}
</div>
{/* Bubble */}
<div className={cn(
'rounded-2xl px-4 py-3 text-sm leading-relaxed',
isUser
? 'bg-indigo-600 text-white rounded-tr-md'
: 'bg-white text-zinc-700 border border-zinc-200/60 shadow-sm rounded-tl-md'
)}>
{isEmpty ? (
<div className="flex items-center gap-1 text-zinc-400">
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-100" />
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse delay-200" />
{/* Body */}
<div className={cn('flex-1 max-w-[78%]', isUser && 'flex flex-col items-end')}>
{/* Thinking indicator */}
{state === 'thinking' && !message.content && !message.reasoningContent && (
<div className="rounded-2xl px-3.5 py-2.5 bg-zinc-50 border border-zinc-100 rounded-tl-md">
<div className="flex items-center gap-2 text-zinc-400 text-sm">
<Brain size={14} className="animate-pulse" />
<span>...</span>
</div>
) : isUser ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
)}
</div>
</div>
)}
{/* Actions - only for assistant messages */}
{!isUser && !isEmpty && (
<div className="flex gap-0.5 mt-1 opacity-0 hover:opacity-100 transition-opacity px-1">
{/* Reasoning block (collapsible) */}
{message.reasoningContent && (
<div className="mb-1.5 w-full">
<button
onClick={() => setReasoningOpen(!reasoningOpen)}
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-600 transition-colors py-1"
>
{state === 'streaming' || state === 'thinking' ? (
<Brain size={12} className="animate-pulse text-indigo-400" />
) : (
<Brain size={12} />
)}
<span>{state === 'done' ? '深度思考' : '思考中...'}</span>
{state === 'done' && (
reasoningOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />
)}
</button>
{(reasoningOpen || state !== 'done') && (
<div className="mt-1 px-3 py-2 rounded-lg bg-indigo-50/50 border border-indigo-100 text-xs text-zinc-500 leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap">
{message.reasoningContent}
</div>
)}
</div>
)}
{/* Main content bubble */}
{!(message.content === '(调用工具)' && state === 'done' && message.reasoningContent) && (
<div className={cn(
'rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed',
isUser
? 'bg-indigo-600 text-white rounded-tr-md'
: 'bg-zinc-50 text-zinc-700 border border-zinc-100 rounded-tl-md'
)}>
{state === 'streaming' && !message.content ? (
<div className="flex items-center gap-1 text-zinc-400">
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse" />
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:100ms]" />
<span className="inline-block w-1.5 h-1.5 bg-zinc-300 rounded-full animate-pulse [animation-delay:200ms]" />
</div>
) : isUser ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<div className="markdown-body prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }} />
)}
</div>
)}
{/* Actions - visible on hover or when done */}
{showActions && (
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity px-1">
<button onClick={handleCopy}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
{copied ? <><Check size={10} /></> : <><Copy size={10} /></>}
</button>
<button onClick={() => onQuote?.(message.content)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<Quote size={10} />
</button>
<button onClick={() => onDelete?.(message.id)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
<Trash2 size={10} />
</button>
<button onClick={() => onRegenerate?.(message.id)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<RefreshCw size={10} />
</button>
<button onClick={() => onContinue?.(message.id)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<button onClick={() => onContinue?.()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<ArrowRight size={10} />
</button>
</div>