199 lines
8.7 KiB
Plaintext
199 lines
8.7 KiB
Plaintext
|
|
abortRef.current?.abort();
|
|||
|
|
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
|||
|
|
dispatch({ type: "reset", ...fresh });
|
|||
|
|
setFiles([]);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="h-full relative overflow-hidden">
|
|||
|
|
{/* ─── Sidebar Overlay ─── */}
|
|||
|
|
{isHistoryOpen && (
|
|||
|
|
<div
|
|||
|
|
className="absolute inset-0 bg-black/20 z-40 transition-opacity duration-300"
|
|||
|
|
onClick={() => setIsHistoryOpen(false)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
className={`absolute top-0 left-0 h-full w-1/4 bg-white border-r border-border z-50 transform transition-transform duration-300 ease-in-out shadow-xl ${
|
|||
|
|
isHistoryOpen ? "translate-x-0" : "-translate-x-full"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="p-6 h-full flex flex-col">
|
|||
|
|
<div className="flex items-center justify-between mb-8">
|
|||
|
|
<h3 className="text-lg font-bold">历史对话</h3>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setIsHistoryOpen(false)}
|
|||
|
|
className="p-2 hover:bg-surface-muted rounded-full transition-colors"
|
|||
|
|
>
|
|||
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="6 18L18 6M6 6l12 12" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 overflow-y-auto">
|
|||
|
|
{state.historySessions.length === 0 ? (
|
|||
|
|
<div className="text-center py-10 text-txt-muted text-sm italic">
|
|||
|
|
暂无历史记录
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex flex-col gap-3 py-2">
|
|||
|
|
{state.historySessions.map((session) => (
|
|||
|
|
<div
|
|||
|
|
key={session.id}
|
|||
|
|
onClick={() => loadHistorySession(session.sessionId)}
|
|||
|
|
className={`p-4 rounded-xl border border-border cursor-pointer transition-all hover:bg-surface-muted hover:border-magenta group ${
|
|||
|
|
state.sessionId === session.sessionId ? 'bg-magenta/5 border-magenta ring-1 ring-magenta' : 'bg-white'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-start mb-2">
|
|||
|
|
<span className="text-[0.7rem] font-mono text-txt-muted truncate max-w-[120px]">
|
|||
|
|
ID: {session.sessionId.slice(0, 8)}...
|
|||
|
|
</span>
|
|||
|
|
<span className="text-[0.65rem] text-txt-muted">
|
|||
|
|
{new Date(session.ts).toLocaleDateString()}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-txt leading-snug line-clamp-2 group-hover:text-magenta transition-colors">
|
|||
|
|
{session.summary}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{loadingHistory && (
|
|||
|
|
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-[60]">
|
|||
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta"></div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* ─── Main Chat ─── */}
|
|||
|
|
<div className="h-full flex flex-col min-w-0">
|
|||
|
|
<div className="flex-1 overflow-y-auto px-[72px] py-8">
|
|||
|
|
{/* History Toggle Button */}
|
|||
|
|
<button
|
|||
|
|
onClick={() => setIsHistoryOpen(true)}
|
|||
|
|
className="absolute top-6 left-6 p-2 bg-white border border-border rounded-lg shadow-sm hover:bg-surface-muted transition-colors z-30 group"
|
|||
|
|
title="查看历史记录"
|
|||
|
|
>
|
|||
|
|
<svg className="w-5 h-5 text-txt-muted group-hover:text-magenta" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-6 w-full">
|
|||
|
|
{state.messages.length === 0 && (
|
|||
|
|
<div className="text-center py-20 card">
|
|||
|
|
<h2 className="text-2xl font-extrabold mb-2">规划委员会代理</h2>
|
|||
|
|
<p className="text-txt-muted text-sm">
|
|||
|
|
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{state.messages.map((msg) => (
|
|||
|
|
<div
|
|||
|
|
key={msg.id}
|
|||
|
|
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
|||
|
|
msg.role === "user"
|
|||
|
|
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
|||
|
|
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{msg.role === "assistant" && (
|
|||
|
|
<span className="badge mb-3 block w-fit">系统 / 迭代闭环</span>
|
|||
|
|
)}
|
|||
|
|
{msg.role === "assistant" && state.reasoning[msg.id] && (
|
|||
|
|
<details
|
|||
|
|
className="mb-4 bg-surface-muted/50 rounded-lg border border-border overflow-hidden"
|
|||
|
|
open={!msg.content}
|
|||
|
|
>
|
|||
|
|
<summary className="px-4 py-2 text-xs font-medium text-txt-muted cursor-pointer hover:bg-border/20 transition-colors list-none flex items-center gap-2">
|
|||
|
|
<span className={`w-1.5 h-1.5 rounded-full bg-magenta ${!msg.content ? 'animate-pulse' : ''}`}></span>
|
|||
|
|
查看思考过程
|
|||
|
|
</summary>
|
|||
|
|
<div className="px-4 py-3 bg-white/50 text-[0.8rem] font-mono whitespace-pre-wrap border-t border-border text-txt-muted">
|
|||
|
|
{state.reasoning[msg.id]}
|
|||
|
|
</div>
|
|||
|
|
</details>
|
|||
|
|
)}
|
|||
|
|
{msg.role === "assistant" ? (
|
|||
|
|
<div className="prose prose-sm max-w-none prose-p:my-0.5 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0">
|
|||
|
|
<ReactMarkdown
|
|||
|
|
remarkPlugins={[remarkGfm]}
|
|||
|
|
rehypePlugins={[rehypeRaw]}
|
|||
|
|
>
|
|||
|
|
{msg.content || (state.chatting ? "思考中..." : "")}
|
|||
|
|
</ReactMarkdown>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
msg.content || "Thinking..."
|
|||
|
|
)}
|
|||
|
|
{msg.status === "failed" && (
|
|||
|
|
<span className="text-red-500 text-xs block mt-2">发送失败</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
<div ref={bottomRef} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Error */}
|
|||
|
|
{state.error && (
|
|||
|
|
<div className="mx-[72px] mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
|||
|
|
{state.error}
|
|||
|
|
<button
|
|||
|
|
className="text-red-400 hover:text-red-600 font-bold"
|
|||
|
|
onClick={() => dispatch({ type: "set-error" })}
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Input area */}
|
|||
|
|
<div className="px-10 pb-6 pt-4 border-t border-border">
|
|||
|
|
{/* File upload row */}
|
|||
|
|
<div className="flex items-center gap-3 mb-3 text-sm">
|
|||
|
|
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
|||
|
|
{uploading ? "上传中..." : "附加文件"}
|
|||
|
|
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
|
|||
|
|
</label>
|
|||
|
|
{files.map((f) => (
|
|||
|
|
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
|||
|
|
))}
|
|||
|
|
<button
|
|||
|
|
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
|||
|
|
onClick={handleReset}
|
|||
|
|
>
|
|||
|
|
重置会话
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex gap-4 items-end">
|
|||
|
|
<textarea
|
|||
|
|
className="input-field flex-1 min-h-[88px] max-h-56 resize-y"
|
|||
|
|
value={input}
|
|||
|
|
onChange={(e) => setInput(e.target.value)}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
send();
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
placeholder="描述你的 Epic 或提供指引..."
|
|||
|
|
disabled={state.chatting}
|
|||
|
|
/>
|
|||
|
|
<button className="btn-magenta" onClick={send} disabled={state.chatting}>
|
|||
|
|
{state.chatting ? "规划中..." : "发送"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|