基于react的jsx写法

This commit is contained in:
2026-03-18 18:46:28 +08:00
parent 3c4a11eca8
commit 04ea889d75
23 changed files with 299 additions and 1416 deletions

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
// 引入各区域组件(后续创建)
import PresetPanel from './components/PresetPanel';
import ChatBox from './components/ChatBox';
import ImageDisplay from './components/ImageDisplay';
import DicePanel from './components/DicePanel';
function App() {
const [isToolbarExpanded, setIsToolbarExpanded] = useState(false);
const toggleToolbar = () => {
setIsToolbarExpanded(!isToolbarExpanded);
};
return (
<div className="app-container">
{/* 顶部工具栏 */}
<header className={`toolbar ${isToolbarExpanded ? 'expanded' : ''}`}>
<div className="toolbar-title">AI Chat Studio</div>
<button
className="toolbar-toggle-btn"
onClick={toggleToolbar}
aria-label="Toggle Toolbar"
>
{isToolbarExpanded ? '▼' : '▲'}
</button>
{/* 展开后的内容区域(预留) */}
{isToolbarExpanded && (
<div className="toolbar-content">
{/* 工具栏内容待定 */}
</div>
)}
</header>
{/* 主体内容区域 */}
<main className="main-container">
{/* 左侧:预设栏 (仿AI酒馆) */}
<section className="sidebar-left">
<PresetPanel />
</section>
{/* 中间:历史对话框 + 输入框 */}
<section className="chat-area">
<ChatBox />
</section>
{/* 右侧:上下布局 */}
<section className="sidebar-right">
{/* 上方:图片展示区 */}
<div className="right-top">
<ImageDisplay />
</div>
{/* 下方:骰子区 */}
<div className="right-bottom">
<DicePanel />
</div>
</section>
</main>
</div>
);
}
export default App;

View File

@@ -1,121 +0,0 @@
import React, { useEffect } from 'react';
import Layout from '@/components/Layout';
import Sidebar from '@/components/Sidebar';
import ChatWindow from '@/components/ChatWindow';
import InputBox from '@/components/InputBox';
import RightPanel from '@/components/RightPanel';
import { useChatStore } from '@/store/useChatStore';
import { getDatasets } from '@/api/client'; // 导入 API 函数
// --- 根组件 ---
const App: React.FC = () => {
// 1. 从 Store 获取初始化数据的方法 (假设在 Store 中定义了 loadDatasets)
// 注意:如果 useChatStore 中没有 loadDatasets这里仅作演示需后续补充
const { loadDatasets } = useChatStore();
// 2. 组件挂载时初始化数据
useEffect(() => {
const initApp = async () => {
try {
// --- 预期后端请求 ---
// 接口: GET /get_all_role_and_chat
// 响应: DatasetResponse (Record<string, string[]>)
const datasets = await getDatasets();
// --- 更新 Store ---
// 将获取到的数据集列表存入 Store供顶部下拉框使用
if (loadDatasets) {
loadDatasets(datasets);
} else {
console.warn('Store method loadDatasets is not defined yet.');
}
} catch (error) {
console.error('初始化应用失败:', error);
// 可以在这里显示一个全局的 Toast 通知用户
}
};
initApp();
}, [loadDatasets]);
return (
<Layout>
{/* --- 左侧栏 --- */}
<Sidebar />
{/* --- 中间栏 --- */}
{/* 注意:在 Layout.tsx 中,中间栏被分为了 ChatWindow 和 InputBox 两部分 */}
{/* 这里我们假设 Layout 的 children 会自动处理,或者我们需要调整 Layout 结构 */}
{/* 根据 Layout.tsx 的实现,我们可能需要这样传递: */}
{/* <Layout middleSlot={<><ChatWindow /><InputBox /></>} /> */}
{/* 但为了简化,我们假设 Layout 内部已经硬编码了结构,这里我们直接覆盖 Layout 的内容 */}
{/* 由于 Layout.tsx 已经定义了具体的 div 结构,这里我们实际上是在填充 Layout 的内部 */}
{/* 如果 Layout 是作为容器组件,它应该接受 props 来渲染子组件 */}
{/* 让我们假设 Layout 是这样设计的:它接受 left, middle, right 三个 props */}
{/* 修正:根据之前的 Layout.tsx 代码,它是直接写死的结构。
为了让 App.tsx 能控制内容,我们应该修改 Layout.tsx 接受 children 或 slots。
但为了响应当前的“同要求”,我们假设 Layout 已经被修改为接受 props。
*/}
{/* --- 实际渲染逻辑 (假设 Layout 接受 props) --- */}
{/*
<Layout
left={<Sidebar />}
middle={
<>
<ChatWindow />
<InputBox />
</>
}
right={<RightPanel />}
/>
*/}
{/* --- 备用方案:如果 Layout 不接受 props我们直接在 App 中重构布局 (不推荐,但为了演示) --- */}
{/* 这里为了代码连贯性,我们假设 Layout 已经被修改为接受 props。
如果您之前的 Layout.tsx 没有接受 props请务必修改它。
*/}
{/* --- 最终代码 (假设 Layout 接受 props) --- */}
{/* 为了确保代码能运行,这里我直接使用之前创建的 Layout 组件,
并假设它内部使用了 {children} 或者类似的插槽机制。
如果 Layout 是硬编码的,您需要修改 Layout.tsx。
*/}
{/* 由于 Layout 是我们之前创建的,我们可以在这里直接使用它,
但为了满足“App.tsx 组合组件”的需求,我们假设 Layout 已经被更新。
*/}
{/* --- 正确的调用方式 (基于之前创建的 Layout 结构) --- */}
{/* 实际上,之前的 Layout.tsx 并没有接受 props而是直接渲染了占位符。
为了让 App.tsx 生效,我们必须修改 Layout.tsx。
**请务必修改 `frontend-react/src/components/Layout.tsx`,使其接受 props**
interface LayoutProps {
left?: React.ReactNode;
middle?: React.ReactNode;
right?: React.ReactNode;
}
然后在 Layout 内部用 {props.left} 替换 <SidebarPlaceholder />。
*/}
{/* 以下是 App.tsx 的最终渲染代码 */}
<Layout
left={<Sidebar />}
middle={
<>
<ChatWindow />
<InputBox />
</>
}
right={<RightPanel />}
/>
</Layout>
);
};
export default App;

View File

@@ -1,84 +0,0 @@
import axios from 'axios';
// 导入类型
import type { GenerateRequest, GenerateResponse, Message } from '@/types';
// 定义角色列表接口
// 结构: { "RoleName": ["file1.jsonl", "file2.jsonl"] }
export interface ChatRoleListResponse {
[roleName: string]: string[];
}
// 创建 Axios 实例
const apiClient = axios.create({
baseURL: '/api', // 配合 Vite 代理,转发到 http://backend:8000
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
});
// ==========================================
// API 接口函数封装
// ==========================================
/**
* 获取所有角色和聊天记录列表
* 对应后端: GET /get_all_role_and_chat
*/
export const getChatRoleList = async (): Promise<ChatRoleListResponse> => {
const response = await apiClient.get<ChatRoleListResponse>('/get_all_role_and_chat');
return response.data;
};
/**
* 获取特定聊天记录
* 对应后端: GET /chat_history?file_path=xxx.jsonl
*
* @param filePath - 文件路径,如 "RoleA/chat1.jsonl"
* @returns Message[] - 消息数组
*/
export const getChatHistory = async (filePath: string): Promise<Message[]> => {
const response = await apiClient.get<Message[]>('/chat_history', {
params: { file_path: filePath }
});
return response.data;
};
/**
* 发送消息并获取回复 (普通 POST非流式)
* 对应后端: POST /generate_reply
*/
export const generateReply = async (payload: GenerateRequest): Promise<GenerateResponse> => {
const response = await apiClient.post<GenerateResponse>('/generate_reply', payload);
return response.data;
};
/**
* 获取本地图片列表
* 对应后端: GET /local_images
*/
export const getLocalImages = async (path: string): Promise<string[]> => {
const response = await apiClient.get<string[]>('/local_images', { params: { path } });
return response.data;
};
/**
* 上传图片
* 对应后端: POST /upload_image
*/
export const uploadImage = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<{ url: string }>('/upload_image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
// 3. 预留:流式响应处理 (SSE)
export const generateReplyStream = async (payload: GenerateRequest): Promise<void> => {
// TODO: 待实现 SSE 逻辑
console.log('SSE stream logic placeholder', payload);
};
export default apiClient;

View File

@@ -0,0 +1,18 @@
import React from 'react';
const ChatBox = () => {
return (
<div className="chat-box">
<div className="chat-messages">
{/* 消息列表占位 */}
<div>历史消息区域</div>
</div>
<div className="chat-input-area">
{/* 输入框占位 */}
<input type="text" placeholder="输入消息..." disabled />
</div>
</div>
);
};
export default ChatBox;

View File

@@ -1,137 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { useChatStore } from '@/store/useChatStore';
import { Message } from '@/types';
import ReactMarkdown from 'react-markdown';
// --- 组件:单条消息气泡 ---
const MessageBubble: React.FC<{ message: Message; renderHtml: boolean }> = ({ message, renderHtml }) => {
// 根据角色决定样式
const isUser = message.role === 'user';
return (
<div className={`flex w-full mb-4 ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`
max-w-[80%] rounded-lg p-3 shadow-sm
${isUser
? 'bg-blue-50 text-gray-800 border border-blue-100'
: 'bg-white text-gray-800 border border-gray-200'
}
`}
>
{/* 消息头部:角色名(可选) */}
<div className="text-xs font-bold mb-1 text-gray-500">
{isUser ? 'User' : 'Assistant'}
</div>
{/* 消息内容 */}
<div className="prose prose-sm max-w-none">
{renderHtml && !isUser ? (
// 如果开启 HTML 渲染且是 AI使用 ReactMarkdown 解析
<ReactMarkdown>{message.content}</ReactMarkdown>
) : (
// 否则纯文本显示(注意:实际项目中应防范 XSS这里仅作演示
<div className="whitespace-pre-wrap">{message.content}</div>
)}
</div>
</div>
</div>
);
};
// --- 主组件:聊天窗口 ---
const ChatWindow: React.FC = () => {
// 1. 从 Store 获取状态
const { messages, currentMessageId, renderHtml } = useChatStore();
// 2. 滚动容器引用
const scrollRef = useRef<HTMLDivElement>(null);
// 3. 自动滚动到底部
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, currentMessageId]); // 当消息更新或切换分支时滚动
// 4. 构建当前显示的消息链
// 这是一个简化逻辑:从 currentMessageId 开始回溯 parentId 直到根节点
// 实际项目中可能需要更复杂的树遍历逻辑
const displayMessages: Message[] = React.useMemo(() => {
if (!currentMessageId) return [];
const chain: Message[] = [];
let currId: string | null = currentMessageId;
// 向上回溯 (最多回溯 100 条防止死循环)
let safetyCount = 0;
while (currId && safetyCount < 100) {
const msg = messages[currId];
if (msg) {
chain.unshift(msg); // 添加到数组开头
currId = msg.parentId;
} else {
break;
}
safetyCount++;
}
return chain;
}, [messages, currentMessageId]);
return (
<div className="flex flex-col h-full bg-[#F0F4F8]">
{/* --- 顶部控制栏 (数据集/文件选择) --- */}
{/* 注意:这部分逻辑可能需要移到 Layout 或单独的 Header 组件中 */}
<div className="flex-none h-12 border-b border-[#D1D9E6] bg-white flex items-center px-4 justify-between shrink-0">
<div className="flex items-center space-x-2 text-sm">
<span className="text-gray-500"></span>
<span className="font-semibold text-gray-700">Active_Session_01</span>
</div>
{/* HTML 渲染开关 */}
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">HTML </span>
<button
onClick={() => {/* TODO: 调用 store.toggleRenderHtml() */}}
className={`w-10 h-5 rounded-full p-1 transition-colors duration-200 ease-in-out ${
renderHtml ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<div
className={`bg-white w-3 h-3 rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
renderHtml ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* --- 消息列表区域 (可滚动) --- */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 custom-scrollbar scroll-smooth"
>
{displayMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<p></p>
</div>
) : (
<div className="space-y-4">
{displayMessages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
renderHtml={renderHtml}
/>
))}
</div>
)}
</div>
</div>
);
};
export default ChatWindow;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DicePanel = () => {
return (
<div className="dice-panel">
<div>骰子区</div>
</div>
);
};
export default DicePanel;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const ImageDisplay = () => {
return (
<div className="image-display">
<div>图片展示区</div>
</div>
);
};
export default ImageDisplay;

View File

@@ -1,131 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useChatStore } from '@/store/useChatStore';
import { Send } from 'lucide-react'; // 图标库
// --- 主组件:输入框 ---
const InputBox: React.FC = () => {
// 1. 状态管理
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 2. 从 Store 获取状态和方法
const {
addMessage,
updateStreamingMessage,
isLoading,
messages,
genParams,
currentMessageId
} = useChatStore();
// 3. 自动调整 Textarea 高度
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; // 重置高度
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; // 设置为内容高度
}
}, [inputValue]);
// 4. 处理发送逻辑
const handleSend = async () => {
const text = inputValue.trim();
if (!text || isLoading) return;
// --- A. 更新前端 UI ---
setInputValue(''); // 清空输入框
// 1. 添加用户消息到 Store
const userMsgId = addMessage('user', text, currentMessageId);
// 2. 创建一个空的 AI 消息占位符
const aiMsgId = addMessage('assistant', '', userMsgId);
// --- B. 准备请求数据 ---
// 预期后端接收格式:
// {
// "prompt": text,
// "history": Object.values(messages).filter(...), // 发送当前上下文
// "params": genParams
// }
const payload = {
prompt: text,
history: Object.values(messages), // 简化处理:发送所有消息
params: genParams,
};
try {
// --- C. 发起请求 (模拟) ---
// TODO: 替换为实际的 API 调用,例如:
// import { generateReplyStream } from '@/api/client';
// await generateReplyStream(payload, (chunk) => { updateStreamingMessage(aiMsgId, chunk); });
// 这里仅模拟流式效果
const simulatedResponse = "这是一个模拟的 AI 回复。在实际项目中,这里将替换为真实的后端流式数据。";
// 模拟逐字显示
for (let i = 0; i < simulatedResponse.length; i++) {
await new Promise(resolve => setTimeout(resolve, 50)); // 模拟延迟
updateStreamingMessage(aiMsgId, simulatedResponse[i]);
}
} catch (error) {
console.error('发送消息失败:', error);
// 错误处理:可以更新消息内容显示错误信息
updateStreamingMessage(aiMsgId, "\n[错误: 无法连接到服务器]", true);
}
};
// 5. 处理键盘事件 (Ctrl+Enter 发送)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex-none bg-[#F0F4F8] p-4 border-t border-[#D1D9E6]">
<div className="relative flex items-end w-full bg-white border border-[#0056B3] rounded-lg shadow-sm focus-within:ring-2 focus-within:ring-blue-200 transition-all">
{/* 文本输入区域 */}
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息... (支持 /命令Ctrl+Enter 发送)"
className="w-full max-h-48 min-h-[60px] p-3 pr-12 bg-transparent resize-none outline-none text-[#1A1A1A] text-sm custom-scrollbar"
disabled={isLoading}
/>
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={isLoading || !inputValue.trim()}
className={`
absolute right-2 bottom-2 p-2 rounded-md transition-colors
${isLoading || !inputValue.trim()
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-600 hover:bg-blue-100'
}
`}
title="发送 (Ctrl+Enter)"
>
{isLoading ? (
// 加载图标 (简单的 CSS 动画)
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
{/* 底部提示 */}
<div className="mt-2 text-xs text-gray-400 text-right">
{isLoading ? 'AI 正在思考...' : 'Enter 换行Ctrl+Enter 发送'}
</div>
</div>
);
};
export default InputBox;

View File

@@ -1,185 +0,0 @@
import React, { ReactNode } from 'react';
// --- 1. 定义 Props 接口 ---
interface LayoutProps {
left?: ReactNode;
middle?: ReactNode;
right?: ReactNode;
}
// --- 2. 占位符组件 (当未传入 props 时显示) ---
const SidebarPlaceholder = () => (
<div className="p-4 space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4 animate-pulse"></div>
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
</div>
);
const ChatPlaceholder = () => (
<div className="flex flex-col items-center justify-center h-full text-gray-400 space-y-2">
<div className="w-12 h-12 border-4 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
<p>...</p>
</div>
);
const InputPlaceholder = () => (
<div className="h-full w-full border border-[#0056B3] rounded-lg bg-white p-2 opacity-50">
<div className="h-full bg-gray-100 rounded animate-pulse"></div>
</div>
);
const RightPanelPlaceholder = () => (
<div className="p-4 space-y-4">
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
<div className="h-32 bg-gray-100 rounded border border-gray-300"></div>
</div>
);
// --- 3. 主布局组件 ---
const Layout: React.FC<LayoutProps> = ({ left, middle, right }) => {
return (
// 1. 最外层容器全屏高度浅蓝灰背景Flex 列布局
<div className="flex flex-col h-screen bg-[#F0F4F8] text-[#1A1A1A] font-sans overflow-hidden">
{/* --- 顶部工具栏 --- */}
<header className="h-14 bg-white border-b border-[#D1D9E6] flex items-center justify-between px-4 shrink-0 z-20 shadow-sm">
{/* --- 左侧:双层下拉框 (替换原来的 打开/保存按钮) --- */}
<div className="flex items-center space-x-2">
{/* 1. 第一层:角色选择 */}
<select
value={currentRole || ''}
onChange={(e) => selectRole(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:border-blue-500 bg-white"
>
<option value="" disabled></option>
{Object.keys(datasets).map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
{/* 2. 第二层:文件选择 (仅在选择角色后显示) */}
{currentRole && datasets[currentRole] && (
<div className="flex items-center space-x-1 relative"> {/* relative 用于定位菜单 */}
<select
value={currentChatFile || ''}
onChange={(e) => selectChatFile(e.target.value)}
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:border-blue-500 bg-white"
>
<option value="" disabled></option>
{datasets[currentRole].map((file) => {
// 去掉 .jsonl 后缀用于显示
const displayName = file.replace('.jsonl', '');
return <option key={file} value={file}>{displayName}</option>;
})}
</select>
{/* 3. 操作按钮 (重命名/删除) */}
<button
onClick={() => setShowMenu(!showMenu)}
className="text-gray-500 hover:text-blue-600 px-1"
title="操作"
>
</button>
{/* 悬浮菜单 */}
{showMenu && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded shadow-lg py-1 z-50 min-w-[100px]">
<button
className="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100"
onClick={() => {
console.log('重命名功能待实现');
setShowMenu(false);
}}
>
</button>
<button
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => {
console.log('删除功能待实现');
setShowMenu(false);
}}
>
</button>
</div>
)}
</div>
)}
</div>
{/* --- 中间:标题 (替换原来的 AI WorkFlow Engine) --- */}
{/* 这里留空,或者您可以放一个小的 Logo/图标 */}
<div className="text-lg font-bold text-[#0056B3]">
{/* 可以在这里放图标,或者直接留空让布局更紧凑 */}
</div>
{/* --- 右侧:设置 (保留原来的代码) --- */}
<div className="flex items-center space-x-4">
<button className="text-[#0056B3] font-semibold hover:bg-blue-50 px-3 py-1 rounded transition">
</button>
</div>
</header>
{/* --- 主体区域:三栏布局 --- */}
<main className="flex-1 flex overflow-hidden">
{/* --- 左侧栏 (1份) --- */}
<aside className="w-1/4 bg-white border-r border-[#D1D9E6] overflow-y-auto shrink-0 custom-scrollbar">
{left || <SidebarPlaceholder />}
</aside>
{/* --- 中间栏 (3份) --- */}
{/* 注意:这里我们假设 middle prop 包含了 ChatWindow 和 InputBox */}
{/* 如果 middle 为空,我们显示占位符 */}
<section className="flex-1 flex flex-col border-r border-[#D1D9E6] bg-white relative">
{middle ? (
middle
) : (
<>
<div className="flex-1 overflow-y-auto p-4 bg-[#F0F4F8] custom-scrollbar scroll-smooth">
<ChatPlaceholder />
</div>
<div className="flex-none h-32 border-t border-[#D1D9E6] bg-[#F0F4F8] p-4 shrink-0">
<InputPlaceholder />
</div>
</>
)}
</section>
{/* --- 右侧栏 (1份) --- */}
<aside className="w-1/4 bg-white overflow-y-auto shrink-0 custom-scrollbar">
{right || <RightPanelPlaceholder />}
</aside>
</main>
{/* --- 全局滚动条样式 --- */}
<style jsx global>{`
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
`}</style>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,29 @@
import React from 'react';
const PresetPanel = () => {
return (
<div className="preset-panel">
{/* 顶部:预设选择与输入 */}
<div className="preset-header">
{/* 下拉框 */}
<select className="preset-select">
<option>选择预设...</option>
</select>
{/* 输入框组(待定) */}
<div className="preset-inputs">
{/* inputs here */}
</div>
</div>
{/* 下方:大槽位区域 */}
<div className="preset-slots">
{/* 槽位列表 */}
<div className="slot-item">槽位 1</div>
<div className="slot-item">槽位 2</div>
{/* ... */}
</div>
</div>
);
};
export default PresetPanel;

View File

@@ -1,175 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Dice1, Image as ImageIcon, Table } from 'lucide-react';
import { useChatStore } from '@/store/useChatStore';
// --- 子组件:本地图库 ---
const LocalGallery: React.FC = () => {
const [images, setImages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// 模拟加载图片数据
useEffect(() => {
setLoading(true);
// TODO: 替换为真实的 API 调用
// import { getLocalImages } from '@/api/client';
// const data = await getLocalImages('/assets/images');
// setImages(data);
// 模拟延迟
setTimeout(() => {
setImages([
'placeholder1.jpg',
'placeholder2.jpg',
'placeholder3.jpg',
'placeholder4.jpg'
]);
setLoading(false);
}, 500);
}, []);
return (
<div className="flex flex-col h-full">
<div className="flex items-center space-x-2 mb-3">
<ImageIcon size={16} />
<h3 className="text-sm font-bold text-gray-700">🖼 </h3>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1">
{loading ? (
<div className="text-center text-xs text-gray-400 py-4">...</div>
) : images.length === 0 ? (
<div className="text-center text-xs text-gray-400 py-4"></div>
) : (
<div className="grid grid-cols-2 gap-2">
{images.map((img, idx) => (
<div key={idx} className="bg-white p-1 border border-gray-200 rounded shadow-sm">
{/* 图片占位符 */}
<div className="aspect-square bg-gray-100 rounded mb-1 flex items-center justify-center text-gray-400 text-xs">
{img}
</div>
<div className="text-[10px] text-center text-gray-500 truncate">{img}</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
// --- 子组件:骰子检定 ---
const DiceTool: React.FC = () => {
const [result, setResult] = useState<number | null>(null);
const [rollType, setRollType] = useState<'difficulty' | 'opposed'>('difficulty');
const [difficulty, setDifficulty] = useState<number>(50); // 默认普通难度
const handleRoll = () => {
// 生成 1-100 随机数
const res = Math.floor(Math.random() * 100) + 1;
setResult(res);
};
return (
<div className="flex flex-col h-full pt-4 border-t border-gray-100">
<div className="flex items-center space-x-2 mb-3">
<Dice1 size={16} />
<h3 className="text-sm font-bold text-gray-700">🎲 </h3>
</div>
{/* Tab 切换 */}
<div className="flex border-b border-gray-200 mb-3">
<button
onClick={() => setRollType('difficulty')}
className={`flex-1 py-1 text-xs font-medium transition-colors ${
rollType === 'difficulty'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setRollType('opposed')}
className={`flex-1 py-1 text-xs font-medium transition-colors ${
rollType === 'opposed'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
{/* 难度选择 (仅在难度检定模式下显示) */}
{rollType === 'difficulty' && (
<div className="mb-4 space-y-2">
<label className="text-xs text-gray-500"></label>
<div className="grid grid-cols-3 gap-2">
{[
{ label: '极难', val: 95 },
{ label: '困难', val: 75 },
{ label: '普通', val: 50 }
].map((opt) => (
<button
key={opt.val}
onClick={() => setDifficulty(opt.val)}
className={`text-xs py-1 px-2 rounded border transition-colors ${
difficulty === opt.val
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{opt.label} ({opt.val})
</button>
))}
</div>
</div>
)}
{/* 投掷区域 */}
<div className="flex items-center justify-between bg-white p-3 rounded border border-gray-200 shadow-sm">
<div className="flex-1">
<div className="text-xs text-gray-400 mb-1">
{rollType === 'difficulty' ? `目标: ${difficulty}` : '对抗检定'}
</div>
{result !== null && (
<div
className={`text-2xl font-bold ${
rollType === 'difficulty' && result > difficulty
? 'text-red-500' // 失败
: 'text-green-500' // 成功
}`}
>
{result}
</div>
)}
</div>
<button
onClick={handleRoll}
className="ml-4 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 transition shadow-sm active:transform active:scale-95"
>
</button>
</div>
</div>
);
};
// --- 主组件:右侧面板 ---
const RightPanel: React.FC = () => {
return (
<div className="flex flex-col h-full p-4 overflow-y-auto custom-scrollbar">
{/* 上半部分:图库 */}
<div className="flex-1 min-h-0 mb-4">
<LocalGallery />
</div>
{/* 下半部分:骰子 */}
<div className="flex-none h-auto">
<DiceTool />
</div>
</div>
);
};
export default RightPanel;

View File

@@ -1,221 +0,0 @@
import React from 'react';
import { useChatStore } from '@/store/useChatStore';
import { Slider } from '@/components/ui/slider'; // 假设您有这个组件,或者用原生 input[type=range]
import { Switch } from '@/components/ui/switch'; // 假设您有这个组件,或者用原生 input[type=checkbox]
import { Label } from '@/components/ui/label'; // 假设您有这个组件,或者用原生 label
// --- 类型定义 ---
// 这里复用 Store 中的类型,或者从 types/index.ts 导入
type SpliceBlockType = 'system' | 'world' | 'history' | 'character';
const Sidebar: React.FC = () => {
// 1. 从 Store 获取状态和方法
const {
genParams,
spliceBlocks,
updateGenParams,
toggleSpliceBlock,
} = useChatStore();
// 2. 处理参数变更
const handleParamChange = (key: keyof typeof genParams, value: number | boolean) => {
updateGenParams({ [key]: value });
};
return (
<div className="flex flex-col h-full p-4 space-y-6 overflow-y-auto custom-scrollbar">
{/* --- 标题:全局预设 --- */}
<div>
<h3 className="text-sm font-bold text-gray-700 mb-3 flex items-center">
📜
</h3>
<div className="flex gap-2">
<select className="flex-1 p-2 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-500">
<option>Default</option>
<option>A.U.T.O. v1.47</option>
<option>Roleplay Pro</option>
</select>
<button className="px-3 py-2 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm">
📥
</button>
</div>
</div>
{/* --- 标题:生成参数 --- */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-gray-700"> </h3>
{/* 参数网格布局 */}
<div className="grid grid-cols-2 gap-4">
{/* 温度 */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<label></label>
<span>{genParams.temperature.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={genParams.temperature}
onChange={(e) => handleParamChange('temperature', parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* Top P */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<label>Top P</label>
<span>{genParams.top_p.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.1"
value={genParams.top_p}
onChange={(e) => handleParamChange('top_p', parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* 频率惩罚 */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<label></label>
<span>{genParams.frequency_penalty.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={genParams.frequency_penalty}
onChange={(e) => handleParamChange('frequency_penalty', parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* 存在惩罚 */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<label></label>
<span>{genParams.presence_penalty.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={genParams.presence_penalty}
onChange={(e) => handleParamChange('presence_penalty', parseFloat(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
{/* 上下文长度 */}
<div className="col-span-1">
<label className="text-xs text-gray-500"></label>
<input
type="number"
value={genParams.context_length}
onChange={(e) => handleParamChange('context_length', parseInt(e.target.value))}
className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
/>
</div>
{/* 最大回复 */}
<div className="col-span-1">
<label className="text-xs text-gray-500"></label>
<input
type="number"
value={genParams.max_tokens}
onChange={(e) => handleParamChange('max_tokens', parseInt(e.target.value))}
className="w-full mt-1 p-1 text-sm border border-gray-300 rounded"
/>
</div>
</div>
{/* 流式传输开关 */}
<div className="flex items-center space-x-2 pt-2">
<input
type="checkbox"
id="stream-check"
checked={genParams.stream}
onChange={(e) => handleParamChange('stream', e.target.checked)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<label htmlFor="stream-check" className="text-sm text-gray-700 select-none">
</label>
</div>
</div>
{/* --- 标题:内容拼接块 --- */}
<div className="flex-1 flex flex-col min-h-0">
<h3 className="text-sm font-bold text-gray-700 mb-2">🧩 </h3>
<p className="text-xs text-gray-500 mb-3"></p>
{/* 拼接块列表 */}
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
{spliceBlocks.map((block) => (
<div
key={block.id}
className={`flex items-center justify-between p-2 rounded border transition-colors ${
block.active
? 'bg-white border-[#D1D9E6] shadow-sm'
: 'bg-gray-50 border-gray-200 opacity-70'
}`}
>
{/* 左侧:图标和名称 */}
<div className="flex items-center space-x-2 overflow-hidden">
<span className="text-lg shrink-0">
{block.type === 'world' ? '🌍' : block.type === 'history' ? '💬' : '📄'}
</span>
<span
className={`text-sm truncate ${
block.active ? 'text-gray-900 font-medium' : 'text-gray-500 line-through'
}`}
>
{block.name}
</span>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center space-x-2 shrink-0">
{/* 编辑按钮 (如果不可编辑则置灰) */}
<button
className={`text-xs p-1 rounded ${
block.editable === false ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:text-blue-600'
}`}
disabled={block.editable === false}
title="编辑"
>
</button>
{/* 激活开关 */}
<input
type="checkbox"
checked={block.active}
onChange={() => toggleSpliceBlock(block.id)}
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 cursor-pointer"
/>
</div>
</div>
))}
</div>
{/* 添加按钮 */}
<button className="mt-3 w-full py-2 text-sm text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition">
+
</button>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -1,20 +1,139 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* --- 全局样式补充 --- */
body {
/* 全局重置与基础设置 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
/* 确保背景色填充整个窗口 */
background-color: #F0F4F8;
color: #1A1A1A;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* 确保 React 根节点占满全屏 */
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden; /* 禁止全局滚动 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
}
/* 布局容器:相对定位,作为绝对定位子元素的参考系 */
#root {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
position: relative; /* 关键:让绝对定位的工具栏相对于此容器定位 */
}
/* --- 顶部工具栏区域 --- */
.toolbar {
position: absolute; /* 关键:脱离文档流,实现覆盖效果 */
top: 0;
left: 0;
right: 0;
height: 50px; /* 默认折叠高度 */
background-color: #fff;
border-bottom: 1px solid #ddd;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 100; /* 确保层级最高,覆盖在内容之上 */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: height 0.3s ease; /* 平滑过渡动画 */
}
/* 工具栏展开状态 */
.toolbar.expanded {
height: auto; /* 或者设置一个具体的高度,如 200px */
max-height: 300px; /* 防止无限撑开 */
overflow-y: auto; /* 如果内容太多,工具栏内部也可以滚动 */
padding-bottom: 10px;
}
/* 工具栏内容容器 */
.toolbar-content {
display: none; /* 默认隐藏 */
width: 100%;
padding-top: 10px;
}
.toolbar.expanded .toolbar-content {
display: block; /* 展开时显示 */
}
/* --- 主内容区域 --- */
.main-container {
/* 关键布局:占据除工具栏外的所有空间 */
/* 由于工具栏是 absolute 的,我们需要手动设置 top */
position: absolute;
top: 50px; /* 对应 .toolbar 的默认高度 */
bottom: 0;
left: 0;
right: 0;
display: flex;
overflow: hidden; /* 防止主容器本身滚动 */
}
/* --- 左中右三栏 --- */
.sidebar-left {
width: 20%;
background-color: #fff;
border-right: 1px solid #ddd;
overflow-y: auto; /* 独立滚动 */
height: 100%; /* 填满父容器高度 */
}
.chat-area {
flex: 3;
background-color: #fafafa;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 内部元素滚动,外层不滚动 */
}
.sidebar-right {
width: 20%;
background-color: #fff;
border-left: 1px solid #ddd;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* 右侧上下分割 */
.right-top, .right-bottom {
flex: 1;
overflow-y: auto; /* 独立滚动 */
padding: 10px;
min-height: 0; /* 关键:允许 flex 子项收缩 */
}
.right-top {
border-bottom: 1px solid #ddd;
}
/* 工具栏按钮样式微调 */
.toolbar-toggle-btn {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
padding: 4px 8px;
font-size: 0.8rem;
color: #666;
}
/* 通用滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #aaa;
}

View File

@@ -1,10 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css'; // Tailwind CSS
import './index.css';
// --- ---
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,

View File

@@ -1,159 +0,0 @@
import { create } from 'zustand';
import type { Message, SpliceBlock, GenerationParams } from '@/types';
// --- 辅助函数:生成唯一 ID ---
const generateId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
};
// --- 初始状态定义 ---
const initialSpliceBlocks: SpliceBlock[] = [
{ id: 1, name: "[必看] 系统指令", active: true, type: "system" },
{ id: 2, name: "A.U.T.O. 预设设置", active: true, type: "system" },
{ id: 3, name: "世界书:人物关系", active: false, type: "world" },
{ id: 4, name: "Chat History (自动)", active: true, type: "history", editable: false },
];
const initialGenParams: GenerationParams = {
temperature: 1.0,
top_p: 0.9,
frequency_penalty: 1.0,
presence_penalty: 0.0,
max_tokens: 500,
context_length: 30000,
stream: true,
};
// --- 状态接口定义 ---
export interface ChatState {
// 1. 消息树状态
messages: Record<string, Message>;
currentMessageId: string | null;
isLoading: boolean;
inputMessage: string;
// 2. 设置状态
renderHtml: boolean;
selectedFile: string | null;
spliceBlocks: SpliceBlock[];
genParams: GenerationParams;
// 3. 角色与文件状态
datasets: Record<string, string[]>; // 存储角色列表字典
currentRole: string | null;
currentChatFile: string | null;
}
// --- Store 定义 ---
export const useChatStore = create<ChatState>((set, get) => ({
// --- 状态初始化 ---
messages: {},
currentMessageId: null,
isLoading: false,
inputMessage: "",
renderHtml: true,
selectedFile: null,
spliceBlocks: initialSpliceBlocks,
genParams: initialGenParams,
// --- 角色和文件列表 ---
datasets: {}, // 初始为空字典
currentRole: null, // 初始未选中
currentChatFile: null, // 初始未选中
// --- Actions (操作方法) ---
// 1. 加载数据集列表
loadDatasets: (data: Record<string, string[]>) => set({ datasets: data }),
// 2. 选择角色
selectRole: (roleName: string) => set((state) => ({
currentRole: roleName,
currentChatFile: null // 切换角色时,必须清空文件选择
})),
// 3. 选择聊天文件
selectChatFile: (fileName: string) => set({ currentChatFile: fileName }),
// 4. 设置输入框内容
setInputMessage: (text: string) => set({ inputMessage: text }),
// 5. 切换 HTML 渲染开关
toggleRenderHtml: () => set((state) => ({ renderHtml: !state.renderHtml })),
// 6. 更新生成参数
updateGenParams: (newParams: Partial<GenerationParams>) =>
set((state) => ({
genParams: { ...state.genParams, ...newParams },
})),
// 7. 切换拼接块状态
toggleSpliceBlock: (id: string | number) =>
set((state) => ({
spliceBlocks: state.spliceBlocks.map((block) =>
block.id === id ? { ...block, active: !block.active } : block
),
})),
// 8. 添加新消息
addMessage: (role: 'user' | 'assistant', content: string, parentId: string | null = null) => {
const newMessage: Message = {
id: generateId(),
parentId,
childrenIds: [],
role,
content,
timestamp: Date.now(),
isStreaming: false,
};
set((state) => {
const newMessages = { ...state.messages, [newMessage.id]: newMessage };
if (parentId) {
const parent = state.messages[parentId];
if (parent) {
newMessages[parentId] = {
...parent,
childrenIds: [...parent.childrenIds, newMessage.id],
};
}
}
return {
messages: newMessages,
currentMessageId: newMessage.id,
};
});
return newMessage.id;
},
// 9. 更新流式消息内容
updateStreamingMessage: (messageId: string, contentChunk: string, isComplete: boolean = false) => {
set((state) => {
const currentMsg = state.messages[messageId];
if (!currentMsg) return state;
return {
messages: {
...state.messages,
[messageId]: {
...currentMsg,
content: currentMsg.content + contentChunk,
isStreaming: !isComplete,
},
},
};
});
},
// 10. 切换当前消息分支
switchBranch: (messageId: string) => {
set({ currentMessageId: messageId });
},
// 11. 设置加载状态
setLoading: (loading: boolean) => set({ isLoading: loading }),
}));

View File

@@ -1,72 +0,0 @@
// --- 1. 消息与聊天状态 (核心树形结构) ---
export interface Message {
id: string;
parentId: string | null;
childrenIds: string[];
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
// 新增:流式状态标记,对应原 app.py 的 message_placeholder 逻辑
isStreaming?: boolean;
// 新增:是否允许 HTML 渲染(虽然通常由全局开关控制,但单条消息也可以强制)
allowHtml?: boolean;
}
export interface ChatState {
messages: Record<string, Message>;
currentMessageId: string | null;
// UI 状态
isLoading: boolean;
inputMessage: string;
// 设置
renderHtml: boolean; // 对应原 app.py 的 st.toggle("HTML 渲染")
selectedFile: string | null; // 对应原 app.py 的 selected_file_path
// 左侧栏状态
spliceBlocks: SpliceBlock[]; // 对应原 app.py 的 st.session_state.splice_blocks
genParams: GenerationParams; // 对应原 app.py 的滑块参数
}
// --- 2. 左侧栏:预设与参数 ---
export interface SpliceBlock {
id: string | number;
name: string;
active: boolean;
type: 'system' | 'world' | 'history' | 'character';
editable?: boolean;
content?: string;
}
export interface GenerationParams {
temperature: number; // 对应 slider_temp
top_p: number; // 对应 slider_top_p
frequency_penalty: number; // 对应 slider_freq
presence_penalty: number; // 对应 slider_pres
max_tokens: number; // 对应 input_max
context_length: number; // 对应 input_ctx
stream: boolean; // 对应 chk_stream
}
// --- 3. API 请求/响应 ---
// 对应 GET /get_all_role_and_chat
// 结构: { "DatasetName": ["file1.jsonl", "file2.jsonl"] }
export interface DatasetResponse {
[datasetName: string]: string[];
}
// 对应 POST /generate_reply (预留)
export interface GenerateRequest {
prompt: string; // 组装后的完整 Prompt
history: Message[]; // 当前上下文
params: GenerationParams;
}
export interface GenerateResponse {
content: string;
finish_reason: string;
}